Add a second factor
Enroll a user's authenticator app with a QR code, then accept each six-digit code exactly once at login.
The phone and your server each hold a copy of the same secret. Every 30 seconds both mix it with the clock and get the same six digits: a time-based one-time password (TOTP, RFC 6238). The library makes and checks codes but keeps no state, as Concepts explains, so remembering which code was used is your job.
#Before you start
- Node.js 22.13 or later (24 or later on the 24 line), or any runtime with the Web Crypto API.
- Rate-limit attempts per account. With the default window, three codes out of a million match a guess.
- Sync the server clock. When good codes still fail, suspect the phone's clock next.
- The secret cannot be hashed, because the server needs it to compute codes. Keep it out of logs, and consider encrypting the column with a key from Derive keys.
#Store the factor
Everything this page needs from your database fits in the small store below. The two-factor recipe uses the same method names and adds recovery codes, so its enableOtp also takes their digests:
export interface OtpRow {
secret: string;
lastStep: number | null;
}
export interface OtpStore {
setPendingSecret(userId: string, secret: string): Promise<void>;
getPendingSecret(userId: string): Promise<string | undefined>;
/** Activates the secret and records `step` as the last accepted code. */
enableOtp(userId: string, secret: string, step: number): Promise<void>;
/** A nullable column reads back as `null`, not `undefined`. */
getOtp(userId: string): Promise<OtpRow | undefined>;
/**
* A conditional write that resolves `true` only when a row changed:
* UPDATE users SET last_otp_step = $2
* WHERE id = $1 AND (last_otp_step IS NULL OR last_otp_step < $2)
*/
advanceStep(userId: string, step: number): Promise<boolean>;
}Two requests with the same code can both pass totpVerify. The conditional write in advanceStep is the turnstile: only one of them gets to change the row.
#Enroll an authenticator app
Enrollment takes two steps, so a QR code that didn't scan properly never locks anyone out:
/** Step 1: render the returned URI as a QR code. Nothing is enabled yet. */
export async function beginEnrollment(store: OtpStore, user: User): Promise<string> {
const secret = generateOTPSecret();
await store.setPendingSecret(user.id, secret);
const account = user.email;
return otpauthURI({ type: "totp", secret, account, issuer: "Acme" });
}
/** Step 2: enable the factor once a code proves the app is set up. */
export async function confirmEnrollment(
store: OtpStore,
userId: string,
code: string,
): Promise<boolean> {
const secret = await store.getPendingSecret(userId);
if (!secret) return false;
const result = await totpVerify(secret, code);
if (!result.valid) return false;
// Saving the step means this code cannot also pass the next login.
await store.enableOtp(userId, secret, result.step);
return true;
}The issuer shows up twice in the URI, as the label prefix and as issuer=, and it's the name the app lists the account under.
#Check a code at login
This runs once the password is already right:
/** At login, after the password: `true` only for a fresh, unused code. */
export async function verifySecondFactor(
store: OtpStore,
userId: string,
code: string | null,
): Promise<boolean> {
const otp = await store.getOtp(userId);
if (!otp) return false;
const result = await totpVerify(otp.secret, code, {
lastAccepted: otp.lastStep ?? undefined,
});
if (!result.valid) return false;
// Two requests carrying the same code can both get this far.
// The conditional write lets exactly one of them through.
return store.advanceStep(userId, result.step);
}lastAccepted:totpVerifyrefuses every step at or before it. A code that worked once can't work again, and an older code can't sneak in after a newer one.?? undefined: your database hands backNULLasnull, and passing that straight through throwsOUT_OF_RANGE.- Lost write: if
advanceStepreturnsfalse, another request got there first with this code. Treat it as a wrong code.
#When it fails
The first four rows return false, and the rest throw:
| Situation | What happens |
|---|---|
| Code is wrong, missing or not a string | totpVerify returns { valid: false, delta: 0 }. |
| Code was already accepted, or is older than the last one | The same false, so a replay looks like a wrong code. |
| Two requests carry the same code at once | Both verify. The conditional write returns false to one of them. |
| The phone's clock and yours differ by more than the window | false. Sync the server clock, then check the phone. |
lastAccepted is null | Throws OUT_OF_RANGE. Convert with ?? undefined. |
| The secret is empty, or a string with no base32 characters | Throws OUT_OF_RANGE. |
digits is outside 6 to 8, period below 1, or window below 0 | Throws OUT_OF_RANGE. |
account or issuer is not a string, is empty, or is broken text | otpauthURI throws INVALID_TYPE, OUT_OF_RANGE or MALFORMED. |
algorithm is a name the library does not know | Throws UNSUPPORTED. |
Errors lists every code and how to handle it.
#Options
Keep algorithm, digits and period at their defaults, the values every authenticator app supports, and identical on both calls. totpVerify(secret, code, options):
| Option | Default | Notes |
|---|---|---|
window | 1 | Steps accepted on each side of the current one. |
lastAccepted | none | The step from the last successful result. |
algorithm | "SHA-1" | Also "SHA-256", "SHA-384" and "SHA-512". |
digits | 6 | 6 to 8. |
period | 30 | Seconds per step. |
time | now | Unix seconds. For tests. |
otpauthURI(options):
| Option | Default | Notes |
|---|---|---|
type | required | "totp", or "hotp" with a counter. |
secret | required | The base32 string from generateOTPSecret(), 20 random bytes. |
account | required | Shown in the app, usually the email. |
issuer | none | Your service name, never "". |
algorithm, digits, period | as above | Must match totpVerify. |
#Going further
Hardware tokens, busy servers and lost phones each need one more piece:
- Counter-based codes (HOTP): hardware tokens count button presses instead of steps.
hotpVerify(secret, code, counter, { window })checkscounterand the nextwindowcounters, with a default window of0. On success, saveresult.counter + 1with the same kind of conditional write. - Reusing a key: each verify already imports the secret only once for the whole window. To skip even that, pass
await importHmacKey(base32Parse(secret, { returnAs: "bytes" }), { algorithm: "SHA-1" }). Decode first, becauseimportHmacKeyreads a string as UTF-8. A key whose hash differs fromalgorithmthrowsOUT_OF_RANGE. - Recovery codes: they're the spare key for a lost phone. Make each with
secureGenerate({ length: 16, uppercase: false, specials: false }), trim and lowercase before hashing, and store the SHA-256digest.consumeRecoveryCodedeletes by value in one atomic statement and succeeds only when a row was deleted. The two-factor recipe has the code. - Showing a code:
totp(secret)returns the code for right now, handy in tests or on an admin screen.