Add two-factor login

Check the password, then the authenticator code, accept each code once even when two requests race with it, and let a user without the phone in with a single-use recovery code.

Two-factor login asks for two things: something the user knows, the password, and something they carry, the phone with an authenticator app. This recipe puts together the password check from Store passwords and the time-based one-time password (TOTP) check from Add a second factor. Recovery codes cover the day the phone goes missing. The topic pages explain each function; this one shows the order they run in and what you save between them.

#Before you start

  • The login() function from Store passwords, with its pepper (the secret option) in PASSWORD_PEPPER.
  • Three columns per user, pending_otp_secret, otp_secret and a nullable last_otp_step, plus a recovery_codes table of (user_id, digest) rows.
  • A database that can run a transaction, a conditional update and a delete that reports how many rows it removed. The bullets below give the SQL.
  • Both sign-in functions take strings. Validate the request body's shape before calling them: a password that is not text makes login() throw.
  • Rate-limit attempts per account. With the default window, three codes out of a million match a guess.

#Enroll an authenticator app

Enrollment works as in Add a second factor, with one addition: once the first code checks out, the user also gets their recovery codes:

two-factor-auth.ts
import { secureGenerate } from "unsecure/generate";
import { hash } from "unsecure/hash";
import { generateOTPSecret, otpauthURI, totpVerify } from "unsecure/otp";

/** Step 1: render the URI as a QR code. Nothing is enabled yet. */
export async function beginEnrollment(
  store: TwoFactorStore,
  user: { id: string; email: string },
): Promise<string> {
  const secret = generateOTPSecret();
  await store.setPendingSecret(user.id, secret);
  return otpauthURI({ type: "totp", secret, account: user.email, issuer: "Acme Notes" });
}

/** Step 2: a valid code enables the factor. Show the returned codes once. */
export async function confirmEnrollment(
  store: TwoFactorStore,
  userId: string,
  code: string,
): Promise<string[] | null> {
  const secret = await store.getPendingSecret(userId);
  if (!secret) return null;
  const result = await totpVerify(secret, code);
  if (!result.valid) return null;
  const recovery = Array.from({ length: 10 }, () =>
    secureGenerate({ length: 16, uppercase: false, specials: false }),
  );
  const digests = await Promise.all(recovery.map((text) => hash(text)));
  // Recording the step means this code cannot also pass the next login
  await store.enableOtp(userId, secret, result.step, digests);
  return recovery;
}

TwoFactorStore stands in for your database, with one method per query:

  • setPendingSecret / getPendingSecret: read and write pending_otp_secret. The factor stays off until enableOtp runs, so a QR code that was scanned wrong can't lock anyone out.
  • enableOtp: in one transaction, sets otp_secret, clears pending_otp_secret, records the confirming code's step as last_otp_step, and replaces every old recovery code digest. Recording the step means the code that confirmed enrollment can't be reused to log in.
  • Recovery codes: 16 random lowercase letters and digits each, saved only as SHA-256 digests. Concepts explains why a random code doesn't need a slow hash. Show the plain codes once, so the user can print or save them.

#Check the code at login

signIn checks the password with login() first, so a wrong password never gets as far as the code:

two-factor-auth.ts
import { login } from "../crypto/store-passwords.ts";

export type SignIn = { ok: true; account: Account } | { ok: false; needsCode?: true };

export async function signIn(
  store: TwoFactorStore,
  email: string,
  password: string,
  code?: string,
): Promise<SignIn> {
  const account = await login(store, email, password);
  if (!account) return { ok: false };
  const otp = await store.getOtp(account.id);
  if (!otp) return { ok: true, account };
  if (code === undefined) return { ok: false, needsCode: true };

  const lastAccepted = otp.lastStep ?? undefined;
  const result = await totpVerify(otp.secret, code, { lastAccepted });
  // Two requests with the same code can both get here: one write wins
  const ok = result.valid && (await store.advanceStep(account.id, result.step));
  return ok ? { ok: true, account } : { ok: false };
}

Two lines make sure each code works only once:

  • lastAccepted: totpVerify refuses every code at or before the last step you accepted. A code stays on screen for 30 seconds, but someone peeking over a shoulder can't use it after the user did. The column reads back as null until the first success, and ?? undefined turns that into "no previous code".
  • advanceStep: two requests with the same code can both pass totpVerify before either one writes. The conditional write is the turnstile: only one of them gets to change the row. So advanceStep must be a conditional write, and false from it is a failed login. Run UPDATE users SET last_otp_step = $2 WHERE id = $1 AND (last_otp_step IS NULL OR last_otp_step < $2) and return whether one row changed.

#Accept a recovery code once

When the phone is lost, the user signs in with the password and one recovery code in place of the six digits:

two-factor-auth.ts
export async function signInWithRecoveryCode(
  store: TwoFactorStore,
  email: string,
  password: string,
  recoveryCode: string,
): Promise<SignIn> {
  const account = await login(store, email, password);
  if (!account) return { ok: false };
  const digest = await hash(recoveryCode.trim().toLowerCase());
  const used = await store.consumeRecoveryCode(account.id, digest);
  return used ? { ok: true, account } : { ok: false };
}
  • Consume by value: consumeRecoveryCode runs DELETE FROM recovery_codes WHERE user_id = $1 AND digest = $2 and returns whether one row was removed. Two requests with the same code can only remove that row once, so only one of them signs in.
  • Normalize first: trimming and lowercasing before hashing means a code typed in capitals, or pasted with a stray space, still matches.

#When it fails

Every refusal below comes back as { ok: false }, which your route turns into one generic error message:

SituationWhat happens
Wrong passwordlogin() returns null, and no code is checked.
Right password, no code sent{ ok: false, needsCode: true }. Show the code field.
Wrong, expired or already used codetotpVerify returns valid: false.
Same code in two concurrent requestsOne advanceStep changes the row. The other returns false, and that request fails.
Clocks differ by more than one 30-second stepThe code falls outside the default window: 1. Sync the server clock, then check the phone's.
Recovery code already usedThe delete removes nothing.
Body field that is not a stringNot a refusal: login() throws INVALID_TYPE for the password, and recoveryCode.trim() throws a TypeError. Validate the body before calling.
Stored OTP secret is emptytotpVerify throws OUT_OF_RANGE. That is a data bug, not a wrong code. See Handle errors.

#Going further

Ways to change the flow while keeping the same checks:

  • Two requests instead of one: this signIn wants the password again along with the code. You can instead hand out a short-lived pending session after the password and ask only for the code next. The TOTP check stays the same.
  • New recovery codes: when a user asks for a fresh set, replace every stored digest in one transaction. The old printed sheet then stops working.
  • Counter-based codes: HMAC-based one-time passwords (HOTP) count button presses instead of the clock, and work the same way with hotpVerify. Persist counter + 1 from the result with the same conditional write. See Add a second factor.