Skip to content

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.

OTPUpdated 19 October 20266 min read

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

  1. Your server makes a 6-digit code, emails it, and keeps a hash of it with an expiry.
  2. The user types the code into your app.
  3. Your server checks it, then creates a Firebase custom token for that user with the Admin SDK.
  4. 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.

Frequently asked questions

More questions? Email us and the answer goes on this page.

hello@signops.dev
Does Firebase Auth support email OTP?

Not as a 6-digit code. Firebase Auth offers email link sign-in, where the user taps a link in the email. For a typed code, generate and check it yourself and sign the user in with a custom token.

Is a custom token safe to use for this?

Yes, if only your server creates it and only after it has checked the code. Custom tokens are created with the Admin SDK and your service account, which never leaves the server.

Why not just use email link sign-in?

Links open in whatever browser the email app chooses, which often is not the app the user started in. A code the user types back into the same screen avoids that, which matters most on phones.

Send your first push today.

Free for 1,000 push devices, 100 approvals a day, 10,000 passkey sign-ins a month and 500 email codes to try. No card until you pass a free amount.