practice · level 3

2FA and TOTP

Why SMS is the floor, hardware keys are the ceiling, and recovery is the weakest link.

200 XP

2FA and TOTP

Single-factor passwords are dead. Anyone with an interesting set of users will be the target of credential-stuffing within hours of a breach being posted. The fix is a second factor: something the user has, on top of the password they know. The factor matters: not all 2FA is created equal, and the difference between SMS and a hardware key is the difference between "slows the attacker down" and "stops the attack."

The phishing-resistance ladder

Order matters. Each rung is harder for the attacker than the rung below it:

Rung Factor Phishing-resistant? Notes
1 SMS-OTP No Vulnerable to SIM-swap, SS7, smishing.
2 TOTP / HOTP app No A phisher who proxies your real login can capture and replay the 6-digit code.
3 Push notification Sort of Better when number-matching is enforced; "MFA fatigue" otherwise.
4 Hardware key (FIDO2 / WebAuthn / passkey) Yes Origin-bound: the browser refuses to sign for fakebank.com.

Where you sit on this ladder is a product decision. For a consumer app, "TOTP available, hardware-key supported" is a defensible default. For high-value targets (admin consoles, financial movement, source-control accounts) — passkeys, no exceptions.

How TOTP actually works

RFC 6238 (Time-based One-Time Password) is HOTP (RFC 4226) with a clock instead of a counter. The full algorithm is short:

  1. At enrollment, the server generates a random base32 secret and shows it to the user as a QR code (an otpauth://totp/... URI). The user scans it into their authenticator app. Both sides now share a secret.
  2. To produce a code:
    • Take the current Unix time, divide by 30 (the step size), floor to an integer T.
    • Compute HMAC-SHA-1(secret, T).
    • Take the last nibble of the HMAC output as an offset, read 4 bytes starting there, mask the high bit (positive integer), modulo 10^6.
    • That's your 6-digit code.
  3. To verify, the server runs the same calculation and compares. Most servers accept T-1, T, T+1 to tolerate clock drift.
otpauth://totp/MyApp:alice@example.com?secret=JBSWY3DPEHPK3PXP&issuer=MyApp&period=30&digits=6&algorithm=SHA1

That URI is the secret. Treat the QR code as sensitive — never log it, never display it after the user has confirmed enrollment with a working code, and rotate it on demand.

SMS is broken — politely

SMS-as-second-factor sounds reasonable until you understand SIM-swap. An attacker calls your carrier, social-engineers a port-out (often using a few facts scraped from LinkedIn and a stolen utility-bill PDF), and within hours your phone number rings on their SIM. Every SMS code now goes to them. Reza Moaiandin documented this in the 2010s; it's an industry-standard attack today.

NIST SP 800-63B explicitly de-recommends SMS for high-value authentication. Twilio (the largest SMS provider) recommends authenticator apps for the same reason.

Use SMS-OTP only as a fallback below TOTP, never as the primary factor for an account that matters.

TOTP is good — until it isn't

A common attack pattern in 2024+ is the adversary-in-the-middle phishing kit (Evilginx, Modlishka, etc). The attacker runs a reverse proxy that mimics the real login page. The victim types their password and TOTP code into the proxy; the proxy forwards both to the real site, captures the resulting session cookie, and the attacker walks away with a logged-in session.

TOTP doesn't help here because there's nothing in a 6-digit code that says "this code is valid only for the real domain." It's a shared secret between the user's app and the server; whoever supplies it gets in.

This is what phishing-resistant means in WebAuthn: the browser cryptographically binds each authentication challenge to the actual origin (bank.com, not bаnk.com with a Cyrillic 'a'). The hardware key signs the challenge with the per-origin keypair it generated at registration. A reverse-proxy attack cannot replay this because the signature is tied to the origin the attacker can't forge.

Recovery is the weakest link

Every 2FA story has a "what if I lose the factor?" branch. That branch is what attackers attack:

  • Recovery codes: Issued at enrollment, one-shot, hashed at rest like passwords. Most products give 8-10 codes; users print them and store them in a password manager. A surprising number of users lose them.
  • Email reset: If your email account is single-factor, your "MFA" is single-factor too — every site you've enabled MFA on can be reset by anyone who breaks into your email. Email is your account-recovery root of trust; protect it accordingly (TOTP minimum, hardware key ideally).
  • Phone-based recovery: Worst of all worlds — re-introduces SIM-swap as a route around even hardware-key-protected accounts.
  • Customer-support recovery: The pleasant-sounding "I lost everything, my pet's name is..." path. This is how many high-profile account compromises actually happen — the attacker calls support and bullies a human into resetting MFA.

Treat recovery as carefully as login. If you require MFA for login, require something equivalent (a fresh hardware-key proof; a video call; a 7-day waiting period) for recovery. Yes, this annoys users. Yes, do it anyway.

What to ship

For a typical web app:

  • Required: at least one MFA factor on every account; a sensible default (TOTP via authenticator app).
  • Strongly recommended: passkeys / WebAuthn as a first-class option, not a hidden setting.
  • Issued at enrollment: 8 single-use recovery codes, hashed at rest.
  • Banned: SMS as the only 2FA factor on an account.
  • Logged: every MFA enrollment, removal, and recovery — with email notifications so the user sees changes they didn't make.
  • Rate-limited: MFA verification, password reset, recovery-code redemption.

Code shape that gets it right

// Verify a 6-digit TOTP, with rate-limiting and audit log.
async function verifyTotp(userId: string, code: string, ip: string) {
  await rateLimit(`totp:${userId}`, { max: 5, windowMs: 15 * 60 * 1000 });
  const user = await getUser(userId);
  const ok = authenticator.verify({ token: code, secret: user.totpSecret });
  await audit({
    action: "auth.mfa.verify",
    actor: userId,
    metadata: { ok, ip, factor: "totp" },
  });
  if (!ok) throw new Error("Invalid code");
}

Three properties that aren't optional: rate-limiting (so 6 digits doesn't equal 1-in-a-million-per-minute brute force), an audit trail (so anomalies are detectable), and an actual cryptographic verify (don't roll your own — use otplib, pyotp, or your framework's first-party module).

Things to never do

  • Do not store TOTP secrets unencrypted. They are AES-encrypted at rest with a key managed in your secret manager.
  • Do not display the TOTP secret after enrollment confirmation. If the user asks to see it again, force them to re-enroll.
  • Do not let MFA-protected accounts be reset via email to a single-factor email account.
  • Do not exempt admins from MFA. Admins are the highest-value targets in your tenant.
  • Do not skip number-matching on push MFA. "Tap to approve" without context is a fatigue attack waiting to happen.

The goal is not 100% security — it is "the cost of attacking my users is high enough that they will go attack someone else's." Phishing-resistant MFA, taken seriously, achieves exactly that.

Tools in the wild

5 tools
  • otplibfree tier

    Node TOTP/HOTP library — the canonical pick for Node servers.

    library
  • pyotpfree tier

    Python TOTP/HOTP — same RFC 6238 wire format.

    library
  • Authenticator apps that read the otpauth:// QR code.

    service
  • Reference hardware security key — FIDO2/WebAuthn + OTP.

    service
  • Origin-bound public-key auth in the browser; the substrate of passkeys.

    spec