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

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:

sequenceDiagram
    participant U as User
    participant A as Authenticator App
    participant S as Server
    U->>S: Request MFA enrollment
    S->>S: Generate secret K via CSPRNG
    S->>S: Encrypt K at rest, mark "pending"
    S-->>U: otpauth:// URI as QR code
    U->>A: Scan QR, store K
    A->>U: Show 6-digit TOTP
    U->>S: Submit code to confirm
    S->>S: Verify within time window
    S->>S: Mark enrollment "active"
    Note over S: Later sign-in
    U->>S: Password + 6-digit TOTP
    S->>S: Verify code, check last-used step
    S-->>U: Elevated session

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.