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.
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.
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.
| Attack | Password alone | + TOTP | + Passkey | Why |
|---|---|---|---|---|
| Credential stuffing | falls | holds | holds | reused password from another site is not enough |
| Database leak of your users | falls | holds | holds | cracked hashes still miss the second factor |
| Brute force / spraying | falls | holds | holds | guessing 6 digits per 30s window is rate-limitable |
| Shoulder surfing, keylogger | falls | partial | holds | TOTP codes expire; passkeys never type a secret |
| Real-time phishing proxy (AiTM) | falls | falls | holds | the code is relayed live; only origin binding stops it |
| SIM swap | falls | SMS falls | holds | the carrier is a social-engineering surface |
| Session-cookie theft (infostealer) | falls | falls | falls | auth already happened; bind sessions and re-auth instead |
| Malware on the device | falls | falls | partial | a hardware key still needs a user gesture per signature |
| Help-desk social engineering | falls | falls | falls | your 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?
| Method | Phishing-resistant | Cost to user | Fails when | Use it |
|---|---|---|---|---|
| Email OTP | no | low | the inbox is the account's own reset channel | low-risk consumer apps only |
| SMS OTP | no | low | SIM swap, SS7 interception, roaming | last resort — but still far better than nothing |
| TOTP app | no | medium | the code is typed into a proxy in real time | the sane default for most products |
| Push approval | no | very low | the user taps approve out of fatigue | with number matching, never bare |
| Hardware OTP fob | no | medium | same relay problem as TOTP | legacy estates |
| WebAuthn / passkey | yes | low | device lost with no second credential enrolled | anything 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)), whereCis 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.
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.
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.
package totp
import (
"crypto/hmac"
"crypto/rand"
"crypto/sha1"
"encoding/base32"
"encoding/binary"
"fmt"
"math"
"strings"
"time"
)
var enc = base32.StdEncoding.WithPadding(base32.NoPadding)
// NewSecret returns a fresh 160-bit secret, base32 encoded.
func NewSecret() (string, error) {
buf := make([]byte, 20)
if _, err := rand.Read(buf); err != nil { // crypto/rand, never math/rand
return "", err
}
return enc.EncodeToString(buf), nil
}
func DecodeSecret(s string) ([]byte, error) {
clean := strings.ToUpper(strings.NewReplacer(" ", "", "-", "", "=", "").Replace(s))
return enc.DecodeString(clean)
}
// HOTP implements RFC 4226 §5.3.
func HOTP(secret []byte, counter uint64, digits int) string {
msg := make([]byte, 8)
binary.BigEndian.PutUint64(msg, counter)
mac := hmac.New(sha1.New, secret)
mac.Write(msg)
sum := mac.Sum(nil) // 20 bytes
offset := sum[len(sum)-1] & 0x0f
bin := binary.BigEndian.Uint32(sum[offset:offset+4]) & 0x7fffffff
return fmt.Sprintf("%0*d", digits, bin%uint32(math.Pow10(digits)))
}
// TOTP implements RFC 6238.
func TOTP(secret []byte, at time.Time, period time.Duration, digits int) string {
counter := uint64(at.Unix()) / uint64(period.Seconds())
return HOTP(secret, counter, digits)
}
import base64
import hashlib
import hmac
import secrets
import struct
import time
def new_secret() -> str:
"""A fresh 160-bit secret, base32 encoded for display."""
return base64.b32encode(secrets.token_bytes(20)).decode().rstrip("=")
def decode_secret(s: str) -> bytes:
clean = s.upper().replace(" ", "").replace("-", "")
clean += "=" * (-len(clean) % 8) # b32decode insists on padding
return base64.b32decode(clean)
def hotp(secret: bytes, counter: int, digits: int = 6) -> str:
"""RFC 4226 section 5.3."""
msg = struct.pack(">Q", counter) # 8 bytes, big-endian
mac = hmac.new(secret, msg, hashlib.sha1).digest() # 20 bytes
offset = mac[-1] & 0x0F # low nibble, 0..15
code = struct.unpack(">I", mac[offset:offset + 4])[0] & 0x7FFFFFFF
return str(code % 10 ** digits).zfill(digits)
def totp(secret: bytes, at: float | None = None,
period: int = 30, digits: int = 6) -> str:
"""RFC 6238."""
counter = int((at if at is not None else time.time()) // period)
return hotp(secret, counter, digits)
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:
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.
The two endpoints
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
/setupinstead 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.
The server side
// ---------- 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.
| Window | Codes valid at once | Tolerated skew | Verdict |
|---|---|---|---|
| 0 | 1 | 0–30s | too strict — fails on ordinary typing latency |
| ±1 | 3 | up to ~60s | the standard, and RFC 6238's own recommendation |
| ±2 | 5 | up to ~90s | acceptable only with strict rate limiting |
| ±10 | 21 | ~5 min | a 21× brute-force discount for no real benefit |
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.
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
crypto.timingSafeEqual(a, b) — throws if lengths differ, so check length first.subtle.ConstantTimeCompare(a, b) == 1, or hmac.Equal for MACs.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.
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.
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.
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.
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
// 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
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.
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.
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.