Skip to content

8.4.7 — MFA, TOTP and Passkeys

An attacker has a valid username and password from a breach dump. They log in, and the site asks for a six-digit code. They stop, because they cannot get it.

The same attacker, a week later, sends the user a link to secure-yourbank-login.com. The user types their password and their six-digit code. The attacker's server relays both to the real bank in real time and is logged in within seconds.

Both stories are about the same second factor. Multi-factor authentication is the highest-value control in this Part — it makes stolen passwords nearly worthless — and most implementations of it can be phished in real time. The distinction between "a second factor" and "a phishing-resistant factor" is the substance of this page.

1. Factors, and what actually counts as two

Something you know — a password, a PIN. Something you have — a phone, a hardware key, a device holding a private key. Something you are — a fingerprint, a face.

Multi-factor means factors from different categories. A password plus a security question is one factor twice — both are things you know, and both are in the same breach dump.

Biometrics are almost never a factor by themselves in practice. Your fingerprint unlocks the device, and the device holds the key. The factor is the device; the biometric is a local unlock. Understanding that resolves a lot of confusion: your fingerprint never leaves the phone and is never sent to the server.

2. SMS one-time codes

Genuinely better than nothing, and the weakest common option.

SIM swapping — an attacker convinces a mobile operator to move the number to their SIM, often with public information about the victim. It is used routinely against high-value accounts.

SS7 interception — the mobile signalling network has weak authentication between operators, so message interception is possible for a well-resourced attacker.

Real-time phishing — the opening story. This affects every code-based factor equally.

And ordinary failure: no signal, roaming, a changed number, delivery delays.

NIST restricted SMS in SP 800-63B, and the honest position is: offer it as an entry option because adoption matters more than perfection, do not make it the only option, and do not use it for high-value accounts or administrators.

3. TOTP, built from scratch

The six-digit code from an authenticator app works with no network connection on either side, which surprises people. The mechanism is a shared secret plus a shared clock.

HOTP (RFC 4226) is the base: an HMAC of a counter, truncated to digits.

TOTP (RFC 6238) makes the counter the current time divided by a step, normally 30 seconds. Both sides compute the same counter without communicating.

ts
import { createHmac } from 'node:crypto';

function hotp(secret: Buffer, counter: number): string {
  const buf = Buffer.alloc(8);
  buf.writeBigUInt64BE(BigInt(counter));                      // (1)
  const mac = createHmac('sha1', secret).update(buf).digest(); // (2)
  const offset = mac[mac.length - 1] & 0x0f;                   // (3)
  const code = ((mac[offset] & 0x7f) << 24 | mac[offset + 1] << 16
              | mac[offset + 2] << 8 | mac[offset + 3]) % 1_000_000;  // (4)
  return code.toString().padStart(6, '0');
}

function verifyTotp(secret: Buffer, submitted: string, lastUsed: number): number | null {
  const counter = Math.floor(Date.now() / 1000 / 30);          // (5)
  for (const c of [counter - 1, counter, counter + 1]) {       // (6)
    if (c <= lastUsed) continue;                                // (7)
    if (timingSafeEqualStr(hotp(secret, c), submitted)) return c;
  }
  return null;
}

(1) The counter as eight big-endian bytes — both sides must agree exactly, and this is where custom implementations break. (2) SHA-1 here is not a weakness: the attack on SHA-1 is collisions (Chapter 8.2.2), and HMAC does not depend on collision resistance. It is specified for compatibility and every authenticator app expects it. (3) Dynamic truncation: the last nibble picks a starting offset, so different parts of the MAC are used each time. (4) Take four bytes, clear the top bit (avoiding sign issues across languages), reduce to six digits. (5) The counter is the current 30-second window — no communication needed. (6) Accept one step either side for clock drift and for the user typing slowly. A wider window means more codes valid at once; ±1 is the standard compromise. (7) Replay prevention. Store the last accepted counter and refuse anything at or below it, or a code shoulder-surfed within its window can be reused.

Enrolment shares the secret through an otpauth:// URI rendered as a QR code:

otpauth://totp/Example:ana@example.com?secret=JBSWY3DPEHPK3PXP&issuer=Example&digits=6&period=30

The secret is base32 (case-insensitive and unambiguous when typed by hand), at least 160 bits. Store it encrypted (Chapter 8.2.1) — a leaked secrets table hands over every user's second factor. And require a valid code before enabling, or a user who scanned nothing locks themselves out.

TOTP's real weaknesses: the server holds a shared secret that can be stolen; a lost phone means lost access without recovery codes; and it is fully phishable in real time, because the user will type the code into whatever asks.

4. Push approval, and MFA fatigue

A notification appears: "Approve sign-in?" Better usability than typing digits, and it introduced its own attack.

MFA fatigue: the attacker with a valid password triggers approvals repeatedly — dozens overnight — until the user taps approve to stop the noise, or taps by accident. This is how several large breaches began in 2022, including one where the attacker also contacted the employee posing at support and asked them to accept.

Number matching is the fix, and it is now default at the major providers: the login screen shows a two-digit number and the user must type it into the app. A user who did not initiate the login has no number to type. It also converts an accidental tap into a deliberate action.

Push approval is still phishable, because a relayed login triggers a genuine push that the user is expecting.

5. WebAuthn and passkeys

This is the first mechanism that is structurally immune to phishing, and the reason is worth stating precisely.

Registration: the browser generates a key pair. The private key never leaves the authenticator — a phone's secure element, a laptop's secure enclave, or a hardware key. The public key is sent to your server and stored against the account. The credential is bound to the origin (https://bank.example.com).

Authentication: the server sends a random challenge; the authenticator signs it — after a local user check such as a fingerprint or PIN — and the browser returns the signature.

ts
// Server verifies the assertion
const expected = {
  challenge: session.challenge,                   // (1)
  origin: 'https://bank.example.com',             // (2)
  rpID: 'bank.example.com',
};
const { verified, newCounter } = await verifyAuthenticationResponse({
  response, expectedChallenge: expected.challenge,
  expectedOrigin: expected.origin, expectedRPID: expected.rpID,
  credential: { publicKey: stored.publicKey, counter: stored.counter },  // (3)
});

(1) A fresh random challenge per attempt, stored in the session, so a signature cannot be replayed. (2) The origin the browser actually saw is included in the signed data. (3) A signature counter that should increase, which can reveal a cloned authenticator.

Why phishing fails, in one sentence: the browser only offers credentials registered for the origin it is on, and the origin is part of what gets signed. On secure-yourbank-login.com the authenticator has no credential for that origin and offers nothing — and even if the attacker relays the challenge, the signature covers their origin and the real server rejects it. The user cannot make the mistake, which is what makes this different from every code-based factor.

Three more properties follow:

  • No shared secret exists. A breach of your database yields public keys, which are worthless. Compare with TOTP secrets and password hashes.
  • Credentials are per-site, so one service learns nothing about another.
  • Discoverable credentials (resident keys) store the user handle on the authenticator, which allows sign-in with no username at all — the "just tap" experience.

Passkeys are discoverable WebAuthn credentials, usually synced through iCloud Keychain, Google Password Manager or a password manager.

The synced-versus-device-bound trade is the real decision:

  • Synced — survives losing a device, works across your devices, dramatically better adoption. The private key is in a cloud account, so its security is that account's security.
  • Device-bound (a hardware key) — the key never leaves; strongest, and losing it means losing that credential.

For consumers, synced passkeys. For administrators and high-value roles, require device-bound hardware keys, and register at least two so a loss is not a lockout.

6. Recovery: the part that undoes everything

Every authentication system is as strong as its recovery path (Chapter 8.4.1). A hardware key means nothing if support resets access after two questions.

Recovery codes — ten single-use codes shown once at enrolment, stored hashed like passwords. The baseline.

A second registered factor — the best answer for anything important: two hardware keys, or a passkey plus a hardware key. Two independent losses are required.

Administrator reset — necessary in organisations, and it must be logged, notified to the user, and ideally require two people for privileged accounts.

A cooling-off period — a self-service recovery that takes 24 hours with notification to every registered contact turns a silent takeover into something the victim can stop.

7. Adaptive authentication

Rather than always asking, ask when the situation is unusual.

Signals: a new device, an unfamiliar location, an impossible travel time between logins, a data-centre or anonymising IP address, an unusual hour, a high-value action, or a failed-attempt history.

Responses, escalating: allow · require the second factor again · require a stronger factor · block and notify.

Step-up authentication is the version worth implementing first, because it is targeted rather than probabilistic: normal use is unchallenged, and changing an email address, adding a payment method or exporting data requires re-authentication regardless of session age. That is exactly the auth_time check from Chapter 8.4.6.

Be careful with risk scoring. False positives block real users at the worst moment, and the "unusual location" signal disproportionately affects people who travel, use mobile networks, or live where IP geolocation is poor. Prefer a step-up prompt over a block, always.

8. Rolling it out

  1. Start with administrators and staff, where the impact is highest and the population is small.
  2. Offer TOTP and passkeys. Add SMS only if adoption data says you must, and never as the only option.
  3. Require it for sensitive actions before requiring it for login — step-up first is far less disruptive and covers the highest-value moments.
  4. Make enrolment recoverable: recovery codes at enrolment, and encourage a second factor.
  5. Then require it, with a notice period and a supported recovery path.
  6. Measure enrolment and support volume, not just the fact that the feature shipped.

And the strategic framing to take away: the direction of travel is not "password plus a code" but "no password". Passkeys remove the shared secret entirely — nothing to phish, nothing to reuse, nothing to steal from your database. The correct long-term target is passwordless with passkeys, and passwords plus a second factor is the bridge.

What the interviewer will push on

"Why is SMS a weak second factor?" SIM swapping, SS7 interception, and real-time phishing — plus delivery failures. Then the balanced position: better than nothing, fine as an entry option because adoption matters, never the only option and never for administrators.

"How does a TOTP code work with no network?" A shared secret plus a counter derived from the clock — HMAC of floor(time/30), dynamically truncated to six digits. Both sides compute it independently. The details that show implementation experience: accept ±1 step for drift, store the last used counter to prevent replay, and encrypt the secrets at rest.

"Why is SHA-1 acceptable in TOTP?" Because the break on SHA-1 is collision resistance, and HMAC's security does not rest on that. It is specified for compatibility and every authenticator expects it. This question separates people who understand hash properties from people who pattern-match on "SHA-1 is broken".

"What is MFA fatigue and how do you stop it?" Repeated push prompts until the user approves to stop the noise — the entry point for several 2022 breaches. Number matching fixes it, because a user who did not initiate the login has no number to type, and it converts an accidental tap into a deliberate one.

"Why are passkeys phishing-resistant when a TOTP code is not?" The credential is bound to the origin and the origin is part of the signed data, so on a lookalike domain the authenticator has no credential to offer and a relayed signature is rejected by the real server. The user cannot make the mistake — that is the structural difference, not better user education.

"Synced or device-bound passkeys?" Synced for consumers, because losing a phone otherwise loses the account and adoption collapses; device-bound hardware keys for administrators, registered in pairs. Naming the trade — the synced key's security is the cloud account's security — is what the question is testing.

One thing to volunteer: point out that adding MFA moves the attack to account recovery, so the recovery path must be designed with the same rigour. Two registered factors beats recovery codes, and a self-service recovery with a 24-hour delay and notifications converts a silent takeover into something the victim can stop.

Recall

  • Multi-factor means different categories — password plus security question is one factor twice. A biometric unlocks the device; the device is the factor, and the biometric never leaves it.
  • SMS is weak (SIM swap, SS7, real-time phishing) and better than nothing. Never the only option, never for administrators.
  • TOTP = HMAC of floor(time/30), dynamically truncated. Works offline because both sides share a secret and a clock. Accept ±1 step, store the last used counter to block replay, encrypt secrets at rest, and require a valid code before enabling. SHA-1 is fine here — HMAC does not rely on collision resistance.
  • MFA fatigue — repeated push prompts until the user approves. Number matching fixes it: a user who did not start the login has no number to type.
  • WebAuthn is phishing-resistant structurally: the credential is bound to the origin and the origin is signed, so a lookalike site gets nothing and a relay is rejected. No shared secret exists — a database breach yields public keys.
  • Discoverable credentials allow username-less login. Passkeys are synced WebAuthn credentials: synced for consumers (survives device loss), device-bound hardware keys for administrators, registered in pairs.
  • Recovery is where MFA is defeated. Recovery codes stored hashed are the baseline; a second registered factor is better; self-service recovery should have a delay and notifications.
  • Step-up authentication before mandatory MFA: challenge on sensitive actions regardless of session age, using auth_time. Prefer a step-up prompt over a block, because risk signals punish travellers and mobile users.

Self-test: Why is a password plus a security question not two factors? · How do both sides compute the same TOTP code with no network? · What does storing the last used counter prevent? · Why does phishing fail against a passkey but not a TOTP code? · What does number matching actually change? · Where does the attack move once MFA is enabled?

Next: 8.4.8 goes to the systems that authenticate people inside a company network — Active Directory, LDAP, Kerberos and NTLM, and what actually happens when you log in to a Windows machine.