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:

second-factor.ts
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:

second-factor.ts
/** 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:

second-factor.ts
/** 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: totpVerify refuses 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 back NULL as null, and passing that straight through throws OUT_OF_RANGE.
  • Lost write: if advanceStep returns false, 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:

SituationWhat happens
Code is wrong, missing or not a stringtotpVerify returns { valid: false, delta: 0 }.
Code was already accepted, or is older than the last oneThe same false, so a replay looks like a wrong code.
Two requests carry the same code at onceBoth verify. The conditional write returns false to one of them.
The phone's clock and yours differ by more than the windowfalse. Sync the server clock, then check the phone.
lastAccepted is nullThrows OUT_OF_RANGE. Convert with ?? undefined.
The secret is empty, or a string with no base32 charactersThrows OUT_OF_RANGE.
digits is outside 6 to 8, period below 1, or window below 0Throws OUT_OF_RANGE.
account or issuer is not a string, is empty, or is broken textotpauthURI throws INVALID_TYPE, OUT_OF_RANGE or MALFORMED.
algorithm is a name the library does not knowThrows 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):

OptionDefaultNotes
window1Steps accepted on each side of the current one.
lastAcceptednoneThe step from the last successful result.
algorithm"SHA-1"Also "SHA-256", "SHA-384" and "SHA-512".
digits66 to 8.
period30Seconds per step.
timenowUnix seconds. For tests.

otpauthURI(options):

OptionDefaultNotes
typerequired"totp", or "hotp" with a counter.
secretrequiredThe base32 string from generateOTPSecret(), 20 random bytes.
accountrequiredShown in the app, usually the email.
issuernoneYour service name, never "".
algorithm, digits, periodas aboveMust 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 }) checks counter and the next window counters, with a default window of 0. On success, save result.counter + 1 with 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, because importHmacKey reads a string as UTF-8. A key whose hash differs from algorithm throws OUT_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-256 digest. consumeRecoveryCode deletes 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.