Firebase Auth has no email OTP. Here is how to add one
Firebase Auth offers email links, not 6-digit email codes. How to build email OTP sign-in with a Cloud Function and a custom token.
Firebase Auth has phone sign-in by SMS and email link sign-in, but no email OTP: no 6-digit code the user types back. Links break on phones because the email app often opens them in a different browser from the one where sign-in started. Here is how to add a real code, keeping Firebase as your login system.
The idea
- Your server makes a 6-digit code, emails it, and keeps a hash of it with an expiry.
- The user types the code into your app.
- Your server checks it, then creates a Firebase custom token for that user with the Admin SDK.
- The app calls
signInWithCustomToken, and the user is signed in to Firebase like any other.
Server: send and check
import { getAuth } from "firebase-admin/auth";
import crypto from "node:crypto";
// Send
export async function sendCode(email) {
const code = crypto.randomInt(0, 1_000_000).toString().padStart(6, "0");
const hash = crypto.createHmac("sha256", process.env.OTP_SECRET).update(email + code).digest("hex");
await db.codes.put(email, { hash, expires: Date.now() + 10 * 60_000, tries: 0 });
await sendEmail(email, `Your code is ${code}`);
}
// Check, then hand back a Firebase custom token
export async function verifyCode(email, code) {
const row = await db.codes.get(email);
if (!row || row.expires < Date.now() || row.tries >= 5) throw new Error("expired");
const hash = crypto.createHmac("sha256", process.env.OTP_SECRET).update(email + code).digest("hex");
if (!crypto.timingSafeEqual(Buffer.from(hash), Buffer.from(row.hash))) {
await db.codes.update(email, { tries: row.tries + 1 });
throw new Error("wrong code");
}
await db.codes.delete(email);
const user = await getAuth().getUserByEmail(email).catch(() => getAuth().createUser({ email, emailVerified: true }));
return getAuth().createCustomToken(user.uid);
}
App: sign in with the token
import { getAuth, signInWithCustomToken } from "firebase/auth";
const { token } = await fetch("/api/verify-code", { method: "POST", body: JSON.stringify({ email, code }) }).then((r) => r.json());
await signInWithCustomToken(getAuth(), token);
What you still have to get right
- Store only a hash of the code, with an expiry and a try count, as above.
- Rate limit sends per email and per IP, and add a bot check on the form.
- Email delivery: a verified domain and a transactional provider, or codes land in spam.
- Cleanup: delete codes once used or expired.
The same with SignOps
SignOps Email OTP does the first two steps (making, storing, sending and checking the code), so your server only creates the custom token:
const { otp_id } = await signops.otp.send({ email, app_name: "Kirana Express" });
// later
const { valid } = await signops.otp.verify({ otp_id, code });
if (valid) return getAuth().createCustomToken(uid);
500 codes are free to try, and Starter includes 1,000 a month.
Sources: Firebase: create custom tokens, Firebase: email link authentication.