Authentication · basics to production

The second factor is just an HMAC of the clock

Two-factor authentication has a reputation for being cryptographic magic. The common case is forty lines of code. This page builds it from nothing — a working authenticator you can watch tick, the server that verifies it, the attacks that beat it, and the thing you should actually deploy instead.

12 sections 5 live demos TypeScript · Go · Python RFC 4226 / 6238 / WebAuthn L3
Live authenticator running in this tab
30
000000

valid for this 30-second step

RFC 4648 base32 · A–Z, 2–7
1 · unix time
2 · counter T
3 · HMAC-SHA1
4 · offset
5 · 31-bit int
6 · mod 10^d

01 — Definitions

What actually counts as a factor

Two-factor authentication means two credentials from different categories. Two credentials from the same category is one factor asked twice — which is where most “we have 2FA” claims quietly fall apart.

know
Something you know — password, PIN, security question, recovery phrase. Cheap, and stealable at scale: a leak, a keylogger or a convincing login page gets it, and the theft leaves no trace.
have
Something you have — a phone running an authenticator, a hardware key, a SIM, a registered device. An attacker needs the object, or a live channel to whoever holds it.
are
Something you are — fingerprint, face, voice. In practice this almost never travels to your server: it unlocks a key on the device, and the device attests to that. You verify the key, not the finger.

The three sentences worth memorising

  • Password + security question is not 2FA. Both are knowledge; both leak from the same breach.
  • A fingerprint on a phone is not a factor you verify. It gates a private key on that device. Your server authenticates the key, and the biometric never leaves the hardware.
  • A second factor only helps if the first one is still required. A “magic link” that logs you straight in is one factor — possession of an inbox.
Pick two credentials — is that really 2FA?

Select two credentials above.

02 — Threat model

What 2FA actually stops

2FA is not a general-purpose shield. It defeats one specific and enormously common thing: an attacker who has the password and nothing else. Knowing precisely where the line sits is what stops you from over-trusting it.

AttackPassword alone+ TOTP+ PasskeyWhy
Credential stuffingfallsholdsholdsreused password from another site is not enough
Database leak of your usersfallsholdsholdscracked hashes still miss the second factor
Brute force / sprayingfallsholdsholdsguessing 6 digits per 30s window is rate-limitable
Shoulder surfing, keyloggerfallspartialholdsTOTP codes expire; passkeys never type a secret
Real-time phishing proxy (AiTM)fallsfallsholdsthe code is relayed live; only origin binding stops it
SIM swapfallsSMS fallsholdsthe carrier is a social-engineering surface
Session-cookie theft (infostealer)fallsfallsfallsauth already happened; bind sessions and re-auth instead
Malware on the devicefallsfallspartiala hardware key still needs a user gesture per signature
Help-desk social engineeringfallsfallsfallsyour recovery path is your real security floor

Read the last three rows again

Stolen sessions, device malware and help-desk resets bypass every factor on the list, because none of them attack the login. This is why mature systems pair 2FA with short sessions, re-authentication for sensitive actions, device-bound tokens, and a recovery process that is at least as strong as the login it can replace.

03 — Choosing

The ladder, worst to best

Every option below is better than a password alone. They are not, however, close to each other, and the single axis that separates them is phishing resistance: can an attacker who owns the page the user is looking at still get in?

MethodPhishing-resistantCost to userFails whenUse it
Email OTPnolowthe inbox is the account's own reset channellow-risk consumer apps only
SMS OTPnolowSIM swap, SS7 interception, roaminglast resort — but still far better than nothing
TOTP appnomediumthe code is typed into a proxy in real timethe sane default for most products
Push approvalnovery lowthe user taps approve out of fatiguewith number matching, never bare
Hardware OTP fobnomediumsame relay problem as TOTPlegacy estates
WebAuthn / passkeyyeslowdevice lost with no second credential enrolledanything you can — especially staff and admins

What the standards bodies say

NIST SP 800-63B classes SMS as a restricted authenticator: permitted, but you must assess the risk and offer an alternative. Read that as a floor, not an endorsement. The practical policy for a new system in 2026: passkeys first, TOTP as the universal fallback, SMS only where a real user population has no other option.

One rule matters more than the ranking: whatever you ship, ship it with a working recovery path and at least one backup factor. The most common way 2FA hurts a product is not a breach — it is a support queue full of locked-out users, which ends with someone disabling the feature.

04 — The algorithm

TOTP, built from nothing

The widget at the top of this page is running the whole of RFC 6238 in about sixty lines. There is no network call, no server, and no randomness at generation time. Both sides hold the same secret and read the same clock, so both compute the same number.

The one-sentence version

Take a shared secret K. Take the current time, divide by 30, floor it — that is a counter both sides agree on without talking. HMAC the counter with the secret, pick four bytes out of the result in a way that depends on the result itself, and keep the last six digits.

Two RFCs, one algorithm

  • RFC 4226 — HOTP. HOTP(K, C) = truncate(HMAC-SHA1(K, C)), where C is a counter incremented per use.
  • RFC 6238 — TOTP. The same function with C = floor(unixtime / period). TOTP is HOTP with a clock wired into the counter — that is the entire difference.

Step 1 — the shared secret

The secret is raw bytes, 20 of them by convention (the HMAC-SHA1 block size). Humans see it as base32 rather than hex or base64 because base32 has no lowercase, no 0/O or 1/l confusion, and survives being read aloud or typed on a phone keyboard.

Step 2 — dynamic truncation

This is the only part of the algorithm that looks arbitrary. HMAC-SHA1 produces 20 bytes and you need six digits. Rather than always taking the first four bytes, HOTP uses the last nibble of the digest to choose where to start, so the extracted window moves with the input and no fixed slice of the digest is ever the only thing under attack.

HMAC-SHA1(K, C) — 20 bytes 1f 86 98 69 0e 02 ca 16 61 85 50 ef 7f 19 da 8e 94 5b 55 5a byte 10 byte 19 offset = 0x5a & 0x0f = 10 — start the window here 0x50ef7f19 & 0x7fffffff mask the sign bit 1357872921 % 1000000 keep the low six digits 872921
Dynamic truncation, using RFC 4226's own worked example. The low nibble of the final digest byte picks the starting offset; four bytes are read big-endian from there; the top bit is masked off so the value is a positive 31-bit integer on every platform; the remainder modulo 106 is the code. Change one bit of the secret or the counter and both the window and the digits move.

Step 3 — the whole thing, in three languages

A complete, spec-conformant implementation. The TypeScript and Python versions below pass the RFC 4226 Appendix D and RFC 6238 test vectors exactly as written.

totp.ts — generation
import { createHmac, randomBytes } from "node:crypto";

const B32 = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";

/** RFC 4648 base32, padding optional — what authenticator apps expect. */
export function base32Decode(input: string): Buffer {
  const clean = input.toUpperCase().replace(/[=\s-]/g, "");
  let bits = 0, value = 0;
  const out: number[] = [];
  for (const ch of clean) {
    const idx = B32.indexOf(ch);
    if (idx < 0) throw new Error(`invalid base32 character: ${ch}`);
    value = (value << 5) | idx;
    bits += 5;
    if (bits >= 8) {
      out.push((value >>> (bits - 8)) & 0xff);
      bits -= 8;
    }
  }
  return Buffer.from(out);
}

export function base32Encode(bytes: Buffer): string {
  let bits = 0, value = 0, out = "";
  for (const b of bytes) {
    value = (value << 8) | b;
    bits += 8;
    while (bits >= 5) {
      out += B32[(value >>> (bits - 5)) & 31];
      bits -= 5;
    }
  }
  if (bits > 0) out += B32[(value << (5 - bits)) & 31];
  return out;
}

/** A fresh 160-bit secret. Must come from a CSPRNG — never Math.random(). */
export function newSecret(): string {
  return base32Encode(randomBytes(20));
}

/** RFC 4226 §5.3 — HMAC, dynamic truncation, modulo. */
export function hotp(secret: Buffer, counter: bigint, digits = 6): string {
  const msg = Buffer.alloc(8);
  msg.writeBigUInt64BE(counter);                      // 8 bytes, big-endian

  const mac = createHmac("sha1", secret).update(msg).digest(); // 20 bytes

  const offset = mac[mac.length - 1] & 0x0f;          // 0..15
  const bin = mac.readUInt32BE(offset) & 0x7fffffff;  // 4 bytes, sign bit cleared

  return String(bin % 10 ** digits).padStart(digits, "0");
}

/** RFC 6238 — HOTP with the clock as the counter. */
export function totp(
  secret: Buffer,
  atMs: number = Date.now(),
  period = 30,
  digits = 6,
): string {
  const counter = BigInt(Math.floor(atMs / 1000 / period));
  return hotp(secret, counter, digits);
}

// SHA-1 here is not a weakness: HMAC-SHA1 has no practical break, and the spec
// fixes it for interoperability. Authenticator apps will not use SHA-256 unless
// you pass algorithm=SHA256 in the URI — and many ignore it even then.

Check it against a real authenticator

Type the secret from the widget at the top of this page into any authenticator app as a manual entry — Google Authenticator, Aegis, 1Password, Bitwarden, anything. The codes will match digit for digit, because the app is running exactly the function above. That is the whole interoperability story: no registration, no handshake, no vendor.

Step 4 — the enrolment URI

A QR code in an authenticator app is not a picture of anything clever. It encodes one URI string, defined by Google's otpauth:// convention, which every app implements:

the entire content of that QR code
otpauth://totp/Acme%20Bank:ada@example.com?secret=JBSWY3DPEHPK3PXP&issuer=Acme%20Bank&algorithm=SHA1&digits=6&period=30

  scheme     otpauth://
  type       totp             (or hotp, for counter-based)
  label      Issuer:account   URL-encoded; shown in the app's list
  secret     base32, no padding, no spaces      <- the only part that is secret
  issuer     repeated as a parameter; apps prefer this over the label prefix
  algorithm  SHA1 | SHA256 | SHA512   many apps silently assume SHA1
  digits     6 | 8                    8 is rare; test your apps before choosing it
  period     seconds per step, 30 by default

This URI is a credential

  • It contains the secret in the clear. Never log it, never put it in an analytics event, never email it, and never render it where a third-party script can read it.
  • Generate the QR server-side or in the browser — never by calling an external chart API with the secret in the URL. That was a real and widespread bug for years.
  • Serve it only inside an authenticated session, only once, and only before the factor is confirmed.

Should you write this yourself?

In production, no — use otplib (Node), pyotp (Python), github.com/pquerna/otp (Go), or whatever your framework ships. They handle the encoding and URI-escaping edge cases. Understand the algorithm anyway: every operational decision in the rest of this page — drift, replay, rate limits, recovery — is about the code around these sixty lines, and no library makes those decisions for you.

05 — Enrolment

Turning it on, without locking anyone out

Enrolment is where most implementations acquire their bugs. The rule that prevents nearly all of them: the factor is not enabled until the user has proved they can produce a code from it. Until that moment the secret is provisional and the account is unchanged.

Browser Server Database Authenticator 1. POST /2fa/setup 2. store secret, status = pending 3. otpauth:// URI + QR 4. user scans — out of band, never touches your server 5. user reads the 6 digits and types them in 6. POST /2fa/confirm { code } 7. verify, then status = active + recovery hashes 8. recovery codes, displayed once
The pending state is the whole design. Steps 4 and 5 happen entirely off your infrastructure — the app never contacts you, which is why TOTP works offline and on a plane. Between step 2 and step 7 the account still has exactly one factor, so a user who closes the tab, loses the phone, or scans a corrupted QR is never locked out.

The two endpoints

routes/twofactor.ts — setup and confirm
import { Router } from "express";
import { newSecret, base32Decode, totp } from "../totp";
import { encrypt, decrypt } from "../crypto/envelope";
import { hashRecoveryCode, newRecoveryCodes } from "../recovery";

const router = Router();

// STEP 1-3 — provision a PENDING secret. Nothing about the account changes yet.
router.post("/2fa/setup", requireSession, async (req, res) => {
  const user = req.user;

  if (user.totpStatus === "active") {
    // Re-enrolling replaces a working factor: make them prove they still hold it.
    return res.status(409).json({ error: "2fa_already_enabled" });
  }

  const secret = newSecret();                       // base32, from a CSPRNG

  await db.totpEnrolment.upsert({
    userId: user.id,
    // Encrypted with a key from your KMS — see the note below.
    secretCiphertext: await encrypt(secret, { aad: `totp:${user.id}` }),
    status: "pending",
    createdAt: new Date(),
    expiresAt: new Date(Date.now() + 10 * 60_000),  // provisioning window
  });

  const label = encodeURIComponent(`Acme:${user.email}`);
  const uri =
    `otpauth://totp/${label}` +
    `?secret=${secret}&issuer=Acme&algorithm=SHA1&digits=6&period=30`;

  // The secret leaves the server exactly once, to the session that asked for it.
  res.json({ uri, secret, expiresIn: 600 });
});

// STEP 6-8 — the user proves the app works. Only now does the factor go live.
router.post("/2fa/confirm", requireSession, async (req, res) => {
  const user = req.user;
  const code = String(req.body.code ?? "").replace(/\s/g, "");

  if (!/^\d{6}$/.test(code)) {
    return res.status(400).json({ error: "invalid_code_format" });
  }
  if (!(await rateLimit.allow(`2fa:confirm:${user.id}`, { max: 10, windowSec: 300 }))) {
    return res.status(429).json({ error: "too_many_attempts" });
  }

  const row = await db.totpEnrolment.find({ userId: user.id, status: "pending" });
  if (!row || row.expiresAt < new Date()) {
    return res.status(400).json({ error: "setup_expired" });   // restart step 1
  }

  const secret = base32Decode(await decrypt(row.secretCiphertext, { aad: `totp:${user.id}` }));

  // Verification proper — drift window and replay guard live here (section 07).
  const match = verifyTotp(secret, code, { window: 1, lastUsedStep: row.lastUsedStep });
  if (!match.ok) {
    return res.status(400).json({ error: "code_did_not_match" });
  }

  const recovery = newRecoveryCodes(10);

  await db.transaction(async (tx) => {
    await tx.totpEnrolment.update(row.id, {
      status: "active",
      lastUsedStep: match.step,          // burn this step immediately
      activatedAt: new Date(),
    });
    await tx.recoveryCodes.replaceAll(
      user.id,
      await Promise.all(recovery.map(hashRecoveryCode)),
    );
  });

  // Tell the human out of band. If they did not do this, they need to know now.
  await mail.send(user.email, "twoFactorEnabled", { at: new Date(), ip: req.ip });

  // Shown exactly once, and never retrievable again.
  res.json({ recoveryCodes: recovery });
});

Enrolment mistakes, in the order they are usually made

  • Activating on /setup instead of /confirm. The user closes the tab after seeing the QR, never scans it, and is now permanently locked out.
  • Storing the secret in plaintext. A read-only SQL injection then mints codes for every user forever. Encrypt with a KMS-held key, bind the ciphertext to the user id with AAD, and keep it out of logs, backups you do not control, and analytics.
  • Not burning the confirmation step. The code used to activate must be recorded as used, or it stays valid for its remaining seconds.
  • Skipping the notification email. An attacker who has the password can enrol their own authenticator and permanently own the account. The email is what makes that recoverable.
  • No expiry on the pending record. Abandoned secrets accumulate, and a stale one can be confirmed months later.
  • Recovery codes generated but never shown, or shown but never hashed. Both are common; both defeat the point.

Why the secret is encrypted rather than hashed

Passwords are hashed because the server never needs the original. A TOTP secret is different: the server must recompute the code, so it needs the plaintext at verification time. That makes it a reversibly stored credential — the same category as an API key you must replay. Envelope-encrypt it with a key that lives in a KMS or HSM, so a database dump alone is not enough. This is also the strongest argument for passkeys, where the server stores only a public key and has nothing worth stealing.

06 — The login flow

Two requests, one session

A 2FA login is not one request with an extra field. It is two, separated by a token that is deliberately not a session: it proves the password step happened, it expires in minutes, it works exactly once, and it can do nothing else in your API.

Step through the flow
Browser Auth API Store Authenticator 1. POST /login 2. load user + password hash 3. 200 { mfa_token } 4. user reads the current code 5. POST /login/2fa 6. verify code, burn the step 7. Set-Cookie: session
Step 1 — the password request

Nothing about this request mentions 2FA. The client does not know yet whether this account has a second factor, and it must not be able to find out before the password is correct.

on the wire
POST /login HTTP/1.1
Content-Type: application/json

{ "email": "ada@example.com", "password": "correct horse battery staple" }

The server side

routes/login.ts — both halves
// ---------- half one: the password ----------
router.post("/login", async (req, res) => {
  const { email, password } = req.body;

  const user = await db.user.findByEmail(email);

  // Always run the hash comparison, even when the user does not exist, so the
  // response time does not reveal which emails are registered.
  const ok = await argon2.verify(user?.passwordHash ?? DUMMY_HASH, password);
  if (!user || !ok) {
    return res.status(401).json({ error: "invalid_credentials" });
  }

  if (user.totpStatus !== "active") {
    return issueSession(res, user);        // no second factor: done
  }

  // A deliberately weak token: proves step one, authorises nothing else.
  const mfaToken = await mfaTokens.create({
    userId: user.id,
    ttlSec: 300,                            // five minutes, not a session lifetime
    singleUse: true,
    boundTo: { ip: req.ip, userAgent: req.get("user-agent") },
  });

  res.json({ mfaRequired: true, mfaToken, methods: ["totp", "recovery"] });
});

// ---------- half two: the factor ----------
router.post("/login/2fa", async (req, res) => {
  const { mfaToken, code } = req.body;

  const pending = await mfaTokens.consume(mfaToken);   // single use, atomically
  if (!pending) {
    return res.status(401).json({ error: "mfa_token_invalid_or_expired" });
  }

  // Rate limit on the ACCOUNT, not just the IP: the attacker controls the IP.
  const allowed = await rateLimit.allow(`2fa:verify:${pending.userId}`, {
    max: 5,
    windowSec: 300,
  });
  if (!allowed) {
    await notify.suspiciousActivity(pending.userId);
    return res.status(429).json({ error: "too_many_attempts" });
  }

  const user = await db.user.find(pending.userId);
  const enrolment = await db.totpEnrolment.findActive(user.id);
  const secret = base32Decode(await decrypt(enrolment.secretCiphertext, {
    aad: `totp:${user.id}`,
  }));

  const result = verifyTotp(secret, code, {
    window: 1,
    lastUsedStep: enrolment.lastUsedStep,
  });

  if (!result.ok) {
    // A recovery code is the documented fallback — try it before failing.
    if (await recovery.consume(user.id, code)) {
      await mail.send(user.email, "recoveryCodeUsed", { ip: req.ip });
      return issueSession(res, user, { requirePasswordChange: false });
    }
    return res.status(401).json({ error: "invalid_code" });
  }

  // Burn the step so the same six digits cannot be replayed for the rest of
  // its window — by this request, or by anyone who watched it.
  await db.totpEnrolment.update(enrolment.id, { lastUsedStep: result.step });

  return issueSession(res, user);
});

The MFA token is where this flow gets broken

  • Do not issue the real session before the second factor. If the session cookie is set at step 3, an attacker with the password simply ignores the 2FA screen and calls your API directly. This is the single most common 2FA bypass found in audits.
  • Do not make the token guessable or long-lived. 128 bits from a CSPRNG, five minutes, single use, stored server-side so it can be revoked.
  • Do not let it authorise anything. It should be accepted by exactly one endpoint. If it is a JWT, give it a scope claim that your normal middleware rejects.
  • Do not accept a user id from the client at step 5. The user is whoever the token says, never whoever the request body says — otherwise the attacker verifies their own code against your account.
  • Do not reveal 2FA status before the password is verified. “This account needs a code” on a wrong password is a free account-existence oracle.

07 — Verification

Hardening the check

Generating a code is thirty lines and has one correct answer. Verifying one is where the engineering is: clocks disagree, users retype, attackers replay, and six digits is a space you can exhaust if nobody is counting.

Clock drift, and why the window is ±1

The user's phone and your server both compute floor(now / 30), and they will not always agree. Phone clocks drift, users take eight seconds to type, and a code copied at 29.5 seconds arrives in the next step. So you accept the neighbouring steps too — but each extra step you accept multiplies the number of codes valid at any instant, which is a direct gift to a brute-force attacker.

WindowCodes valid at onceTolerated skewVerdict
010–30stoo strict — fails on ordinary typing latency
±13up to ~60sthe standard, and RFC 6238's own recommendation
±25up to ~90sacceptable only with strict rate limiting
±1021~5 mina 21× brute-force discount for no real benefit
Verification sandbox — uses the secret from the widget above live
Fill with a code from

Fill a code and press Verify. Accepting a code burns its step — verify the same one twice and watch it fail.

Replay is a real attack, and the fix is one column

A TOTP code is valid for up to 90 seconds across the window. Anyone who observes it — over a shoulder, in a screen share, in a log file you accidentally wrote it to, or through a phishing proxy — can use it again within that time. The defence is to record the step that was consumed and refuse anything at or below it.

verify.ts — drift, replay, and constant time
import { timingSafeEqual } from "node:crypto";

/** Compare without leaking how many leading digits matched. */
function constantTimeEquals(a: string, b: string): boolean {
  const ba = Buffer.from(a, "utf8");
  const bb = Buffer.from(b, "utf8");
  if (ba.length !== bb.length) return false;   // length is not secret here
  return timingSafeEqual(ba, bb);
}

export type VerifyResult =
  | { ok: true; step: number; driftSteps: number }
  | { ok: false; reason: "no_match" | "replayed" };

export function verifyTotp(
  secret: Buffer,
  submitted: string,
  opts: { window?: number; lastUsedStep?: number; period?: number; digits?: number; now?: number },
): VerifyResult {
  const { window = 1, lastUsedStep = -1, period = 30, digits = 6, now = Date.now() } = opts;

  const code = submitted.replace(/\s/g, "");
  if (code.length !== digits || !/^\d+$/.test(code)) return { ok: false, reason: "no_match" };

  const current = Math.floor(now / 1000 / period);

  for (let drift = -window; drift <= window; drift++) {
    const step = current + drift;

    // Compare EVERY candidate: returning early on the first match leaks, via
    // timing, which step matched — and therefore the client's clock offset.
    if (!constantTimeEquals(hotp(secret, BigInt(step), digits), code)) continue;

    // The code is real. Has this step already been spent?
    if (step <= lastUsedStep) return { ok: false, reason: "replayed" };

    return { ok: true, step, driftSteps: drift };
  }

  return { ok: false, reason: "no_match" };
}

// The caller MUST persist result.step as lastUsedStep, in the same transaction
// that establishes the session. Verifying and recording in two separate steps
// leaves a race two concurrent requests can both win.

Constant-time comparison, per language

Node
crypto.timingSafeEqual(a, b) — throws if lengths differ, so check length first.
Go
subtle.ConstantTimeCompare(a, b) == 1, or hmac.Equal for MACs.
Python
hmac.compare_digest(a, b).

Is a timing side channel on a six-digit code realistically exploitable over the internet? Usually not. It costs one function call to remove, the reviewer will ask, and the same habit protects the places where it genuinely matters.

Rate limiting: the control that actually stops brute force

Six digits is a million possibilities, and with a ±1 window three of them are live at any moment. Unlimited guessing breaks that in hours. Limited to five attempts per five minutes per account, an attacker gets roughly one in seventy thousand odds per hour — which is a rounding error next to the alerting you will have fired by then.

  • Limit on the account, not only the IP. The attacker chooses the IP; they do not choose the account.
  • Count failures, not requests, and reset the counter on success.
  • Escalate rather than lock. Hard lockout is a denial-of-service vector against your own users: anyone who knows an email can lock it. Prefer exponential backoff plus a notification, and reserve lockout for extreme cases.
  • Alert on the pattern. Many accounts each seeing a handful of 2FA failures is credential stuffing meeting your second factor and losing — you want to see that, and to force password resets for the accounts whose passwords were evidently known.
  • Apply the same limits to recovery codes, which are usually longer but are checked by the same endpoint.

Also worth doing

  • Re-authenticate for sensitive actions. Changing the password, email, or 2FA settings, and adding a payout account, should each demand the factor again regardless of session age.
  • Bind “remember this device” to a real cookie with its own signed, revocable token, a bounded lifetime (30 days), and a list in the account UI showing every trusted device with a revoke button. It is a genuine security/UX trade — make it visible and reversible.
  • Serve your own clock. Run NTP. A server whose clock has drifted by two minutes rejects every correct code in your fleet at once, and it looks exactly like an outage.
  • Log the outcome, never the input. Record user, result, drift, and IP. Never write the submitted code, the secret, or the URI.

08 — Recovery

Recovery codes are the real password

Phones are lost, wiped, and dropped in rivers. Whatever path you offer around the second factor becomes the weakest way into the account — so it deserves more design attention than the factor it bypasses, not less.

recovery.ts — generate, store, consume
import { randomInt, createHash } from "node:crypto";
import argon2 from "argon2";

const ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"; // no I, O, 0, 1

/** Ten codes of 10 characters ≈ 50 bits each — unguessable, still typeable. */
export function newRecoveryCodes(count = 10): string[] {
  return Array.from({ length: count }, () => {
    let out = "";
    for (let i = 0; i < 10; i++) out += ALPHABET[randomInt(ALPHABET.length)];
    return `${out.slice(0, 5)}-${out.slice(5)}`;   // ABCDE-FGHIJ
  });
}

/**
 * Hash them. They are bearer credentials that fully replace the second factor,
 * so a database dump must not yield working ones.
 */
export function hashRecoveryCode(code: string) {
  return argon2.hash(normalise(code), { type: argon2.argon2id });
}

function normalise(code: string) {
  return code.toUpperCase().replace(/[\s-]/g, "");   // forgive formatting
}

/** Single use, and the check must not reveal which code was close. */
export async function consume(userId: string, submitted: string): Promise<boolean> {
  const candidate = normalise(submitted);
  if (candidate.length !== 10) return false;

  const rows = await db.recoveryCodes.findUnused(userId);

  for (const row of rows) {
    if (await argon2.verify(row.hash, candidate)) {
      // Mark used atomically — two concurrent requests must not both succeed.
      const claimed = await db.recoveryCodes.markUsedIfUnused(row.id);
      if (!claimed) return false;

      await mail.send(userId, "recoveryCodeUsed", { remaining: rows.length - 1 });
      return true;
    }
  }
  return false;
}

The rules that make recovery safe

  • Show them exactly once, at enrolment, with a download and a print option — and make the user confirm they stored them before you finish enrolling.
  • Hash them like passwords. They are password-equivalent; argon2id or bcrypt, not SHA-256 alone.
  • One use each, marked atomically. A conditional update, not read-then-write.
  • Show the remaining count in account settings, and let the user regenerate — which must invalidate every previous code.
  • Email on every use. A recovery code being spent is exactly the event a victim needs to hear about immediately.
  • Rate-limit them on the same counter as TOTP attempts.

Your real security floor is the help desk

Every attacker who cannot phish the code will simply call support and say they lost their phone. If that conversation can disable 2FA, then the strength of your authentication is the strength of a support agent under time pressure. Decide the policy deliberately: identity re-verification, a mandatory waiting period with notification to every registered channel, approval by a second person for privileged accounts, and a full audit trail. This is a product and operations decision, and it belongs in the same design document as the code.

09 — The limit

Why TOTP still gets phished

Everything so far is correctly implemented and still loses to one attack, and it is the attack actually being run at scale. A six-digit code is a string with no idea where it is going. If the user can be persuaded to type it into the wrong place, it works perfectly there.

A — TOTP: the code is a string, and any page can collect it Victim evil-acme.com reverse proxy acme.com password + 6 digits relayed within seconds Set-Cookie: session — the proxy keeps it B — Passkey: the browser signs where it is, and the signature does not travel Victim evil-acme.com reverse proxy acme.com expects acme.com signature over origin = evil-acme.com relayed unchanged — and rejected on arrival
The difference is one field the user cannot see or retype. In A, every credential is a string the victim types, so relaying them costs the attacker nothing. In B the authenticator signs over the origin the browser is actually on; the proxy cannot alter that signature without breaking it, and cannot obtain a valid one for acme.com from a user standing on evil-acme.com. Phishing resistance is this property and nothing else.

Toolkits that automate this — Evilginx, Modlishka, and the phishing-as-a-service kits built on them — are point-and-click. They proxy the real login page, so the victim sees genuine content, a real certificate, and a working login. The password and the code are captured on the way through and replayed instantly, and what the attacker keeps is the session cookie, which means your second factor was satisfied exactly once, by the real user, on the attacker's behalf.

Pick an attack — see which factors survive it

What helps if you cannot deploy passkeys tomorrow

  • Bind the session to the device. A stolen cookie replayed from another device or IP-reputation profile should force re-authentication.
  • Shorten sessions for privileged roles, and re-authenticate before every dangerous action.
  • Alert on impossible travel and new-device sign-in, and make the alert actionable — a one-click "this was not me" that kills every session.
  • Teach the one durable signal: the domain in the address bar. Everything else on a phishing page can be perfect.
  • Move staff and admins to passkeys first. They are the accounts worth proxying, and the smallest population to migrate.

10 — The upgrade

WebAuthn and passkeys

WebAuthn replaces the shared secret with a keypair. Your server stores a public key — worthless if stolen — and every login is a fresh challenge signed by a private key that never leaves the authenticator, over data that includes the origin the browser is actually on.

Server (RP) Browser Authenticator 1. random challenge + rpId 2. challenge + the REAL origin user gesture unlocks the private key 3. signature + counter 4. assertion posted back verify: challenge, origin, rpId, counter, signature
The browser, not the user, supplies the origin. Step 2 is the security property: the browser refuses to hand a credential scoped to acme.com to a page served from anywhere else, and it stamps the true origin into the signed data. Nothing the user can be talked into typing changes that.

Registration, in the browser

client.ts — navigator.credentials.create
// 1. Ask the server for options. The challenge MUST be server-generated,
//    random, single-use, and remembered for the verification step.
const options = await fetch("/webauthn/register/options", { method: "POST" })
  .then((r) => r.json());

// 2. The browser prompts; the authenticator generates a keypair and signs.
const credential = await navigator.credentials.create({
  publicKey: {
    challenge: base64urlToBuffer(options.challenge),
    rp: { id: "acme.com", name: "Acme" },   // must match the page's origin
    user: {
      id: base64urlToBuffer(options.userId), // opaque; NOT an email or a counter
      name: "ada@example.com",
      displayName: "Ada Lovelace",
    },
    pubKeyCredParams: [
      { type: "public-key", alg: -7 },      // ES256 — universally supported
      { type: "public-key", alg: -257 },    // RS256 — older Windows Hello
    ],
    authenticatorSelection: {
      residentKey: "preferred",             // "preferred" ⇒ a discoverable passkey
      userVerification: "preferred",        // PIN or biometric, not just presence
    },
    timeout: 60_000,
    excludeCredentials: options.existing,   // stop double-registering one key
  },
});

// 3. Send the attestation back. The server stores the PUBLIC key only.
await fetch("/webauthn/register/verify", {
  method: "POST",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify(serialiseCredential(credential)),
});

Verification, on the server

server.ts — @simplewebauthn/server
import {
  generateAuthenticationOptions,
  verifyAuthenticationResponse,
} from "@simplewebauthn/server";

const rpID = "acme.com";
const origin = "https://acme.com";

router.post("/webauthn/login/options", async (req, res) => {
  const options = await generateAuthenticationOptions({
    rpID,
    userVerification: "preferred",
  });

  // The challenge is the anti-replay device: store it against this attempt and
  // delete it after one use.
  await challenges.put(req.sessionID, options.challenge, { ttlSec: 120 });
  res.json(options);
});

router.post("/webauthn/login/verify", async (req, res) => {
  const expectedChallenge = await challenges.take(req.sessionID); // single use
  if (!expectedChallenge) return res.status(400).json({ error: "no_challenge" });

  const stored = await db.credentials.findById(req.body.id);
  if (!stored) return res.status(400).json({ error: "unknown_credential" });

  const verification = await verifyAuthenticationResponse({
    response: req.body,
    expectedChallenge,
    expectedOrigin: origin,   // ← the phishing defence, in one field
    expectedRPID: rpID,
    credential: {
      id: stored.id,
      publicKey: stored.publicKey,
      counter: stored.counter,
    },
    requireUserVerification: true,
  });

  if (!verification.verified) return res.status(401).json({ error: "bad_assertion" });

  // A counter that went backwards means the credential was cloned. Hardware
  // keys increment it; many phone-based passkeys always report 0 — treat 0 as
  // "not supported", and any decrease as an incident.
  const next = verification.authenticationInfo.newCounter;
  if (stored.counter > 0 && next <= stored.counter) {
    await security.flagPossibleClone(stored.userId, stored.id);
    return res.status(401).json({ error: "counter_regression" });
  }
  await db.credentials.updateCounter(stored.id, next);

  return issueSession(res, stored.userId);
});

Second factor, or the whole login?

The same technology answers both. A security key as a second factor follows the password. A passkey is discoverable and user-verified, so it proves possession of the device and the PIN or biometric that unlocked it — two factors in one gesture, which is why passkey sign-in can legitimately replace password-plus-code rather than being bolted on after it.

What WebAuthn does not solve

  • Account recovery. Lose every registered device and you are back to email or a support agent — which is exactly the weak path phishing-resistant login was meant to close. Require at least two credentials before enforcing.
  • Synced passkeys move with the cloud account. A passkey in an iCloud or Google account is as reachable as that account. For high-assurance use, require a device-bound key with attestation.
  • Session theft still works. Nothing in this section protects a cookie already issued.
  • rpId must be chosen carefully. It is the registrable domain and cannot be changed later without re-enrolling every user. Get it right the first time.

11 — Push

Push approval, and the fatigue attack

Push is the best second factor for user experience and, deployed naively, one of the worst for security. If approval is a single button, an attacker with the password can simply send the prompt over and over until a tired human taps it.

This is not theoretical. Several of the largest breaches of the last few years began with an attacker holding valid credentials and spamming push prompts through the night until one was accepted. The fix is to make approval require information that only someone looking at the real login screen has.

push.ts — number matching and context
import { randomInt } from "node:crypto";

export async function startPushChallenge(userId: string, req: Request) {
  // Number matching: the browser shows a number, the phone offers three.
  // Tapping "approve" is no longer possible without seeing the login screen.
  const answer = String(randomInt(10, 100));            // two digits
  const decoys = [String(randomInt(10, 100)), String(randomInt(10, 100))];

  const challenge = await pushChallenges.create({
    userId,
    answer,
    choices: shuffle([answer, ...decoys]),
    ttlSec: 120,
    attemptsAllowed: 1,                                  // one wrong tap ends it
    context: {                                           // shown on the phone
      ip: req.ip,
      approxLocation: await geo.city(req.ip),
      app: "Acme Web",
      at: new Date(),
    },
  });

  // Throttle HARD. Fatigue attacks are a volume attack; volume is the signal.
  const recent = await pushChallenges.countRecent(userId, { windowSec: 600 });
  if (recent > 3) {
    await security.lockPush(userId, { minutes: 30 });
    await mail.send(userId, "suspiciousPushVolume", { ip: req.ip });
    throw new TooManyChallenges();
  }

  return { challengeId: challenge.id, displayNumber: answer };  // shown in the BROWSER
}

export async function resolvePushChallenge(challengeId: string, tapped: string) {
  const c = await pushChallenges.consume(challengeId);   // single use
  if (!c) return { ok: false, reason: "expired" };

  if (tapped !== c.answer) {
    await security.recordFailedApproval(c.userId);       // a wrong tap is a signal
    return { ok: false, reason: "wrong_number" };
  }
  return { ok: true, userId: c.userId };
}

If you ship push

  • Number matching is mandatory, not a premium feature. Without it, push is a button that says “let the attacker in”.
  • Show context on the phone — app, approximate location, IP, time. Give the user enough to notice that they are not the one logging in.
  • Give the prompt a “this wasn't me” button that locks the account and forces a password reset. Treat every press as a confirmed credential compromise.
  • Rate-limit challenge creation aggressively and alert on bursts. Repeated prompts to one user is an attack in progress, not a UX problem.
  • Expire in about two minutes, single use, one attempt.

12 — Shipping it

Rollout, and what to check before you do

The engineering is a week. The rollout is the part that decides whether adoption is 4% or 80%, and whether support drowns.

1 · optional
Ship enrolment, recovery codes, and the account-settings UI. Instrument enrolment starts, completions, and drop-off — a large gap between the two means your QR step is broken on some platform.
2 · prompted
Ask at login, with a “remind me later” that actually delays rather than dismisses forever. Most of your adoption comes from this step.
3 · enforced for staff
Admins, support agents, and anyone with production access. These accounts are worth attacking individually and are a small enough group to migrate to passkeys directly.
4 · enforced by risk
Require it for sensitive actions, new devices, and unusual locations before requiring it for every login.
5 · enforced broadly
With a long runway, repeated notice, and a fully staffed recovery path. Never on a Friday.

Instrument these five, from day one

  • Enrolment funnel — started, QR displayed, first code confirmed. Drop-off here is a bug, not user apathy.
  • Verification failure rate by drift bucket. A spike at drift ±1 means your server clock is sliding.
  • Recovery-code usage, absolute and as a share of logins. Rising usage means people are losing devices and you need a better backup factor.
  • Support tickets tagged “locked out” per thousand enrolled users. This is the number that gets 2FA switched off if you ignore it.
  • Failed attempts per account per hour — your credential-stuffing detector, and it works even when the attack fails.

Pre-launch checklist

  • Secrets are encrypted at rest with a KMS-held key, never logged.
  • The factor activates only after a code is confirmed.
  • The pending enrolment expires and is cleaned up.
  • The confirming code is burned, not left valid.
  • No session cookie is issued before the second factor.
  • The MFA token is single-use, short-lived, and scoped to one endpoint.
  • The user identity comes from the token, never the request body.
  • Drift window is ±1, and the server runs NTP.
  • Used steps are recorded, so codes cannot be replayed.
  • Verification and recording happen in one transaction.
  • Comparison is constant-time.
  • Attempts are rate-limited per account and per IP.
  • 2FA status is never revealed before the password is verified.
  • Recovery codes are hashed, single-use, and shown exactly once.
  • Enabling, disabling, and recovery each send an out-of-band notification.
  • Disabling 2FA requires the current factor, not just the password.
  • Sensitive actions re-authenticate regardless of session age.
  • Trusted devices are listed and individually revocable.
  • The help-desk reset path is documented, rate-limited, and audited.
  • Staff and admin accounts are on phishing-resistant factors.

If you remember four things

  • TOTP is HMAC of a counter derived from the clock. Everything else is plumbing.
  • The session must not exist until the second factor is verified.
  • A code that has been used must never work again.
  • Your recovery path is your real authentication strength. Design it first, not last.