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 (thesecretoption) inPASSWORD_PEPPER. - Three columns per user,
pending_otp_secret,otp_secretand a nullablelast_otp_step, plus arecovery_codestable 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:
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 writepending_otp_secret. The factor stays off untilenableOtpruns, so a QR code that was scanned wrong can't lock anyone out.enableOtp: in one transaction, setsotp_secret, clearspending_otp_secret, records the confirming code's step aslast_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:
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:totpVerifyrefuses 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 asnulluntil the first success, and?? undefinedturns that into "no previous code".advanceStep: two requests with the same code can both passtotpVerifybefore either one writes. The conditional write is the turnstile: only one of them gets to change the row. SoadvanceStepmust be a conditional write, andfalsefrom it is a failed login. RunUPDATE 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:
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:
consumeRecoveryCoderunsDELETE FROM recovery_codes WHERE user_id = $1 AND digest = $2and 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:
| Situation | What happens |
|---|---|
| Wrong password | login() 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 code | totpVerify returns valid: false. |
| Same code in two concurrent requests | One advanceStep changes the row. The other returns false, and that request fails. |
| Clocks differ by more than one 30-second step | The code falls outside the default window: 1. Sync the server clock, then check the phone's. |
| Recovery code already used | The delete removes nothing. |
| Body field that is not a string | Not a refusal: login() throws INVALID_TYPE for the password, and recoveryCode.trim() throws a TypeError. Validate the body before calling. |
| Stored OTP secret is empty | totpVerify 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
signInwants 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. Persistcounter + 1from the result with the same conditional write. See Add a second factor.