Multi-Factor Authentication: TOTP and FIDO2

A single stolen password is the root cause of most account takeovers, and multi-factor authentication is the control that breaks that single point of failure by demanding evidence from more than one independent category. This page — part of the Modern Authentication Fundamentals guide — walks through the MFA factor model, time-based one-time passwords standardized in RFC 6238, and FIDO2/WebAuthn as a phishing-resistant second factor, with production TypeScript using otplib and explicit guidance on the misconfigurations that quietly defeat MFA in practice.

Prerequisites

Before wiring MFA into an authentication flow, you should have in place:

  • A working primary credential step (password, or a passwordless first factor). MFA is layered on top of a session, so you need the session model from Understanding Session vs Token Authentication settled first.
  • A server-side user store where you can persist a per-user MFA secret and enrollment state. The secret must be encrypted at rest (see below).
  • A CSPRNG and an encryption key managed in a KMS or secrets manager — never a hardcoded constant.
  • TLS everywhere. One-time codes transit in plaintext at the application layer; without transport encryption they are trivially sniffed.
  • For FIDO2: an HTTPS origin (or localhost for development) and a stable Relying Party ID, because WebAuthn binds credentials to the origin.

The Factor Model: Something You Know, Have, Are

Authentication factors fall into three independent categories, and “multi-factor” means combining factors from different categories — two passwords are not MFA.

  • Something you know — a password, PIN, or passphrase. Cheap, but phishable and reusable.
  • Something you have — a device that holds a secret or key: a phone running an authenticator app (TOTP), a hardware security key (FIDO2), or a registered platform authenticator.
  • Something you are — a biometric: fingerprint or face. In WebAuthn this is a local user-verification gesture that unlocks a private key; the biometric never leaves the device.

The strength of a second factor is not just whether it exists but whether it resists phishing and replay. A TOTP code can be relayed by a real-time phishing proxy because the user types it into whatever page asks. A FIDO2 assertion cannot, because the authenticator signs a challenge bound to the origin and the browser refuses to produce a signature for the wrong origin.

Second-factor strength ladder Four bars showing increasing security from SMS OTP, to TOTP, to FIDO2 hardware key, annotated with phishing and replay resistance. Second-factor strength SMS / email OTP SIM-swap, phishable, replayable TOTP authenticator app No SIM risk, still phishable in real time FIDO2 / WebAuthn security key Origin-bound signature: phishing-resistant, non-replayable Weaker Stronger
Second-factor strength ladder

How TOTP Works: HOTP With a Time Counter

TOTP (RFC 6238) is a thin layer over HOTP (RFC 4226). HOTP computes a one-time code as HOTP(K, C) = Truncate(HMAC-SHA1(K, C)), where K is a shared secret and C is an 8-byte counter. HOTP increments C on each use; the obvious problem is that client and server counters drift out of sync.

TOTP fixes the synchronization problem by deriving the counter from the clock: C = floor((currentUnixTime - T0) / X), where T0 is the Unix epoch (0) and X is the time step, conventionally 30 seconds. Both sides compute the same counter from the same wall clock, so no stateful increment is needed. The truncation step takes the low-order bits of the HMAC, reduces modulo 10^d, and produces the familiar 6-digit code (d = 6). RFC 6238 permits SHA-256 and SHA-512 variants, but the de facto interoperable default for authenticator apps remains HMAC-SHA1 with 6 digits and a 30-second step.

Because the code is purely a function of (secret, time), two truths follow that drive every hardening decision below: the same code is valid for the whole 30-second window, and a code can be replayed within that window unless the server records that it was already used.

The enrollment-then-verify lifecycle looks like this:

TOTP enrolment and later sign-in The server generates a shared secret, stores it encrypted in a pending state and shows it as a QR code; the authenticator app derives codes from it; enrolment only becomes active once the user submits a live code, and at sign-in the server verifies the code and records the consumed time step. User Authenticator app Server Enrolment start enrolment otpauth:// QR · secret stored encrypted, pending scan · app keeps the secret shows a 6-digit code submit live code → enrolment becomes active Later sign-in password + current code verify, record the consumed step, elevate the session
Enrolment is only complete when the user proves the secret arrived — a pending state that never activates is the difference between a working second factor and a lockout.

The full enrollment-and-verification build — generating the secret, rendering the otpauth:// QR, applying a tight verification window, and tracking the last-used step for replay protection — is covered in implementing TOTP enrollment and verification in Node.

Step-by-Step: A Minimal TOTP Second Factor

Phase 1 — Configure otplib with explicit parameters

Never rely on library defaults for security-sensitive parameters. Pin the algorithm, digits, and step explicitly so a dependency upgrade cannot silently change them.

import { authenticator } from "otplib";

// Explicit, interoperable RFC 6238 parameters — no implicit defaults.
authenticator.options = {
  algorithm: "sha1", // de facto standard for authenticator apps
  digits: 6,
  step: 30, // X = 30s time step
  window: 1, // accept the previous + next step only (±30s)
};

export function generateSecret(): string {
  // otplib uses a CSPRNG internally; 20 bytes = 160-bit base32 secret.
  return authenticator.generateSecret(20);
}

Phase 2 — Build the provisioning URI

The otpauth:// URI (the Key URI format) encodes the secret, issuer, and account label. The authenticator app reads it from a QR code.

import { authenticator } from "otplib";

export function buildOtpAuthUri(secret: string, accountEmail: string): string {
  return authenticator.keyuri(
    accountEmail,
    "Acme Identity", // issuer — shows as the account name in the app
    secret,
  );
  // -> otpauth://totp/Acme%20Identity:user@acme.com?secret=...&issuer=Acme%20Identity&algorithm=SHA1&digits=6&period=30
}

Render that URI as a QR with a server-side QR library and send only the image to the browser. Do not expose the raw secret in client-readable HTML beyond the one-time enrollment screen.

Phase 3 — Verify a submitted code

import { authenticator } from "otplib";

export function verifyTotp(token: string, secret: string): boolean {
  // `window: 1` (set in Phase 1) tolerates exactly one step of clock skew.
  return authenticator.verify({ token, secret });
}

A passing verify is necessary but not sufficient — without last-used-step tracking the same code is replayable for up to 90 seconds with window: 1. The replay-protection logic and at-rest encryption belong in the verification path and are detailed in the enrollment guide above.

Validation & Testing

Confirm the contract before shipping:

  • Cross-app interop: scan the generated QR with Google Authenticator, 1Password, and Aegis; all three must show codes that verify.
  • Window behavior: advance a test clock by 31 seconds and assert the previous code now fails, while a freshly generated one passes.
  • Replay: submit a valid code twice in the same window; the second submission must be rejected.
  • Rate limiting: script 100 wrong codes against one account and assert lockout/backoff engages well before brute force becomes feasible (6 digits = only one in a million per attempt, but unthrottled that falls in minutes).
# Smoke-test the verify endpoint rejects a replayed code.
CODE=123456
curl -s -X POST https://app.example.com/mfa/verify -d "code=$CODE" -b "$COOKIE"   # 200
curl -s -X POST https://app.example.com/mfa/verify -d "code=$CODE" -b "$COOKIE"   # 401 replay

Common Misconfigurations

Misconfiguration Symptom Fix
No replay protection A code shoulder-surfed or phished is reusable within its window Persist the last accepted time-step per user; reject any code whose step is <= the last accepted step
Time window too wide window: 5 or higher accepts codes for several minutes, widening the phishing window Use window: 1 (±30s); fix server clock drift with NTP rather than widening the window
Secret stored unencrypted A database leak exposes every user’s TOTP secret, forging codes forever Encrypt secrets with an envelope key from a KMS; the column should hold ciphertext, not base32
No rate limiting 6-digit codes are brute-forceable in hours without throttling Per-account and per-IP rate limits with exponential backoff and lockout after N failures
SMS used as the “have” factor SIM-swap and SS7 interception bypass MFA entirely Treat SMS OTP as a last resort; prefer TOTP, and FIDO2 for high-value accounts
Pending enrollment trusted A secret is marked active before the user proves possession Require one valid code before activating; until then the factor is “pending” and not enforced

FIDO2/WebAuthn: The Phishing-Resistant Factor

TOTP raises the cost of attack but does not eliminate phishing: a real-time proxy can relay the 6-digit code as fast as the user types it. FIDO2 closes that gap. In a WebAuthn ceremony (a W3C specification, with CTAP2 as the authenticator transport), the authenticator generates an asymmetric key pair scoped to the Relying Party ID. Authentication signs a server challenge bound to the origin; the browser will not release a signature to a look-alike domain, so a phishing site receives nothing usable. The private key never leaves the authenticator, and each assertion carries a signature counter that detects cloned credentials.

This is why high-assurance flows pair a phishing-resistant factor with step-up authentication for sensitive actions — you require a fresh, strong factor immediately before an irreversible operation rather than trusting a hours-old login. The full registration and assertion ceremonies, including @simplewebauthn/server, are the subject of the dedicated passkeys and WebAuthn walkthrough, where FIDO2 is also used as a primary passwordless credential.

Factor Phishing-resistant Replay-resistant NIST AAL ceiling
SMS / email OTP No No AAL1
TOTP authenticator No Yes (with last-step tracking) AAL2
Push approval Partial (number matching helps) Yes AAL2
FIDO2 / WebAuthn key Yes Yes AAL3

Assurance Levels

NIST SP 800-63B frames MFA in terms of Authenticator Assurance Levels. AAL1 allows single-factor; AAL2 requires two distinct factors and is the baseline for most authenticated applications; AAL3 requires a hardware-based, phishing-resistant authenticator with verifier-impersonation resistance — in practice, FIDO2. Mapping these levels onto sessions matters operationally: record which AAL a session reached and the time the factor was presented, so policy can demand re-verification when a request exceeds the session’s current assurance. Recovery is the soft underbelly of any MFA deployment — if a reset flow drops the user back to a single factor, you have spent engineering effort for nothing, which is why generating and storing MFA recovery codes must be hashed, single-use, and rate-limited like any other credential.

Security Implications

The threat model for MFA centers on four attacks. Phishing/relay is mitigated only by origin-bound factors (FIDO2); TOTP narrows but does not close it. Replay within a TOTP window is closed by last-used-step tracking. Brute force against 6-digit codes is closed by rate limiting and lockout (OWASP ASVS V2.2). Secret disclosure via database compromise is closed by KMS-backed encryption at rest. Above all, ensure the second factor is actually enforced: a frequent and damaging bug is checking MFA on the login page but allowing API tokens, password-reset flows, or “remember this device” cookies to mint full sessions that bypass it entirely. Every path that establishes an authenticated session must run through the same factor policy.

Choosing Which Factor to Offer

Offering every factor is not generosity; it is a security decision, because an account is only as strong as its weakest enrolled factor. An attacker who can phish an SMS code does not care that the user also owns a security key.

Second factors ranked by phishing resistance and recovery cost SMS codes are the weakest and are phishable and SIM-swappable; time-based codes resist SIM swapping but remain relayable; push approval adds context but suffers fatigue attacks; security keys and passkeys are origin-bound and resist relaying entirely. Weakest to strongest SMS code SIM swap · phishable TOTP app no carrier · still relayable Push with number matching context · fatigue risk Security key (FIDO2) origin-bound · not relayable Passkey origin-bound · syncs across devices The account's real strength is its weakest enrolled factor and its recovery path Offering SMS as a fallback for a security key means the account is protected by SMS.
Rank the factors, then decide which ones may satisfy which operations. A high-value action should demand a phishing-resistant factor, not merely "some second factor".

That leads to a policy rather than a toggle. Decide which factors satisfy ordinary login, which satisfy step-up for sensitive actions, and which may be used for recovery. A workable default: any enrolled factor for login, a phishing-resistant factor for changing security settings or moving money, and recovery codes only for regaining access — never for authorising a sensitive operation directly.

Rate limiting is the other half of the design, and it is where implementations quietly fail. A six-digit code has a million possibilities and a validity window of a minute or two with skew; without a per-account attempt limit, an attacker with a modest request rate has meaningful odds. Limit verification attempts per account (not per IP, which rotates), lock the factor after a handful of failures, and record every failure — a burst of wrong codes on one account is one of the clearest compromise signals you will ever get.

Frequently Asked Questions

Is SMS-based MFA better than nothing?

Usually yes for credential-stuffing defence, and no for targeted attacks. SMS stops the automated attacker replaying a leaked password, which is most of the volume. It does not stop a targeted attacker, who can phish the code in real time or move the number to their own SIM. Offer it if it materially increases adoption, but never let it satisfy a step-up check for a sensitive operation, and always let users replace it with something stronger.

How much clock skew should TOTP verification tolerate?

One time step either side — thirty seconds before and after — which covers ordinary device drift and a user typing slowly. Widening the window multiplies the number of simultaneously valid codes and directly weakens the factor. If you see many failures that a wider window would have accepted, the fix is server-side time synchronisation and clearer UX about the countdown, not more tolerance.

Do I need to prevent the same code being used twice?

Yes. A code remains valid for the whole time step, so without replay protection an attacker who observes one — over the user’s shoulder, in a phishing proxy, or in a log — can reuse it within that window. Store the last consumed time step per user and reject any submission for a step less than or equal to it. It is a single extra column and it closes the window entirely.

Where should the TOTP secret be stored?

Encrypted at rest with a key held outside the database — an envelope key from a key-management service — because the secret is a symmetric credential: anyone who reads it can generate valid codes indefinitely. Hashing is not an option, since verification needs the original value. Restrict which service can decrypt it, log every decryption, and treat a dump of that table as a full MFA compromise requiring re-enrolment.

Should MFA be required for everyone?

Require it for anyone who can affect other people — administrators, support staff with impersonation rights, anyone with access to billing or production — and make it strongly encouraged for everyone else, with the friction placed at the moments users are most receptive: after a successful login, after a security-relevant change, or when they first access something valuable. Mandating it universally overnight without a recovery path generates lockouts, and lockouts generate the manual account-recovery process that attackers target next.

Enrolment as a Product Problem

The technical work behind a second factor is small; the adoption work is not, and a factor nobody enrols protects nobody. Three choices move the numbers more than anything in the code.

Ask at the right moment. A prompt immediately after a successful login — when the user has proved who they are and is not blocked from anything — converts far better than a banner in settings or a wall in front of a task they came to do. Explain what happens if they lose the device in the same screen, because that unanswered question is what makes people postpone.

Make the second credential the default, not an afterthought. Users who enrol exactly one factor generate the lockouts and the manual recovery requests that attackers later target; users who enrol two rarely contact support at all. Offering the second enrolment straight after the first, while the concept is fresh, costs one extra screen and removes most of the downstream cost.

Show the state honestly. A list of enrolled factors with names, dates and a working removal path — guarded by a step-up challenge — turns MFA from a thing that happened once into something users can manage. It also gives support a straightforward answer when someone changes phone, which otherwise becomes an identity-proofing conversation nobody wants to have.