2FA and TOTP
Why SMS is the floor, hardware keys are the ceiling, and recovery is the weakest link.
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:
- 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. - 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.
- Take the current Unix time, divide by 30 (the step size), floor to an integer
- To verify, the server runs the same calculation and compares. Most servers accept
T-1,T,T+1to 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- libraryotplibfree tier
Node TOTP/HOTP library — the canonical pick for Node servers.
- librarypyotpfree tier
Python TOTP/HOTP — same RFC 6238 wire format.
- service
Authenticator apps that read the otpauth:// QR code.
- service
Reference hardware security key — FIDO2/WebAuthn + OTP.
- specWebAuthn (browser API)free tier
Origin-bound public-key auth in the browser; the substrate of passkeys.