auth · Lesson 10 · Node + Express

MFA & Step-Up Auth

Every method so far has been a single proof of identity. Multi-factor auth stops betting everything on one — it combines proofs of different kinds, so cracking one isn't enough. Today's star is TOTP, the 6-digit authenticator-app code. You'll implement it from scratch in ~20 lines and prove your implementation correct against the official RFC test vector — the same math Google Authenticator runs.

🎯 Mission: a mental model of modern auth + the ability to wire it into a real app
The one idea

Strong auth combines factors of different types, not more of the same type. The three types: something you know (password), something you have (phone/authenticator/passkey), something you are (biometric). Two passwords aren't MFA; a password + a TOTP code are. TOTP works because both sides derive the same short-lived code from a shared secret + the current time — no code is ever transmitted to be intercepted.

01 The three factor types (and what counts)

"Multi-factor" specifically means proofs from different categories. Stacking two of the same kind barely helps — an attacker who can phish one password can phish two. The leverage comes from forcing an attacker to compromise two unrelated things at once.

Something you KNOW

Password, PIN, security question. Weakest — phishable, guessable, reused. (Lesson 4.)

Something you HAVE

Your phone (TOTP/push), a security key, a passkey. The device is the proof. (This lesson + Lesson 9.)

Something you ARE

Fingerprint, face. Usually unlocks a "have" factor locally (e.g. Touch ID releasing a passkey) rather than traveling over the wire.

💡 Where the course's methods land Password = know. TOTP / magic link / OTP / passkey = have (you control the seeded device / inbox / key). A passkey unlocked by Face ID quietly combines have + are in one tap — which is why a passkey alone is often considered strong enough without a separate second factor. See Pangea — MFA with TOTP.

02 How TOTP works — a shared secret + the clock

TOTP (Time-based One-Time Password, RFC 6238) is beautifully simple. At enrollment, server and app agree on one shared secret (the QR code you scan). After that, nothing is ever exchanged — both sides independently compute the same code from secret + current time. Three steps:

  1. counter = floor(unixSeconds / 30) — the current 30-second window, so the code changes every 30s.
  2. hmac = HMAC-SHA1(secret, counter) — a keyed hash binding the secret to this time window.
  3. truncate → a 31-bit number → mod 10⁶ → the 6-digit code you see.
shared secret (from the QR) ⌊time / 30⌋ counter HMAC-SHA1 + dynamic truncation 287 082 6-digit code (30s) app shows it server recomputes = Same inputs on both sides → same output. No code ever crosses the network.
The shared secret never moves after enrollment. Both sides run the same HMAC over the same time-counter and get the same 6 digits — so verification is just "does mine match yours?"

03 Enrollment & verification details that matter

The QR is an otpauth:// URI

otpauth://totp/MyApp:you?secret=BASE32&issuer=MyApp. The app stores the secret; that's the whole handshake.

Accept ±1 window

Phones drift. Check the current step and the adjacent ones (valid_window=1) so a slightly-off clock still works.

Mark codes used

A code is valid for ~30s — within that window, record an accepted code so it can't be replayed twice.

Protect the secret + recovery

Encrypt secrets at rest; issue one-time backup codes at enrollment for a lost device.

04 🎯 Your tangible win (5 min): implement TOTP, prove it correct

A zero-dependency lab implements RFC 6238 in ~20 lines and checks it against the spec's official test vector — then shows enrollment, login, clock-skew tolerance, and expiry.

# in your terminal (no npm install needed):
cd ~/projects/learn/public/courses/auth/practice/totp-lab
node demo.js
🎯 The win — your code matches the standard, verbatim
PROOF — official RFC 6238 test vector (seed "12345…", T=59, 8 digits):
  expected: 94287082
  ours:     94287082    ✅ MATCH — our implementation is correct

login:        app shows 274917  → server verify {"ok":true,"drift":0}
clock skew:   code from 30s ago → {"ok":true,"drift":-1}  (accepted)
expiry:       code from 5 min ago → {"ok":false}
Matching 94287082 means your 20 lines are byte-for-byte the same algorithm as every authenticator app on earth — you could scan the lab's otpauth:// URI into Google Authenticator and the codes would line up. That's the payoff: TOTP isn't a black box anymore. Open demo.js; the whole algorithm is the totp() function.

05 Where TOTP fits — and its real weakness

TOTP is a second factor (something you have), layered on a first (password or — increasingly — a passkey). It massively raises the bar: a leaked password alone no longer logs anyone in. But be honest about its ceiling:

⚠️ TOTP is phishable — it does not beat a real-time phishing proxy A phishing site can ask for your password and your current TOTP code and relay both to the real site within the 30-second window. TOTP stops offline credential reuse, not a live man-in-the-middle. The only factor that resists this is the origin-bound passkey from Lesson 9 — which is exactly why passkeys are positioned to replace both passwords and TOTP, not sit beside them. See Authgear — what is TOTP.

Step-up authentication — MFA only when it matters

You don't need the second factor on every click. Step-up auth asks for it only for sensitive actions — changing a password, a large transfer, viewing secrets — while normal browsing rides the existing session. The pattern: record when and how strongly the user last authenticated, and re-challenge if a high-risk action needs a fresher/stronger proof.

function requireRecentMfa(maxAgeMs) {                 // Express middleware
  return (req, res, next) => {
    const { mfaAt } = req.session;
    if (!mfaAt || Date.now() - mfaAt > maxAgeMs)
      return res.redirect('/verify-mfa?next=' + encodeURIComponent(req.originalUrl));
    next();
  };
}
app.post('/account/delete', requireRecentMfa(5 * 60_000), handler); // re-prove within 5 min

06 Check yourself

Q1. A site requires your password and a security-question answer. Is that MFA? Why or why not?

Show answer

No — both are "something you know," so it's two factors of the same type. An attacker who phishes or researches one can usually get the other, and neither requires possession of a device. Real MFA combines different categories (know + have, e.g. password + TOTP). (§01.)

Q2. TOTP never sends the code over the network during login setup — so how does the server know what code to expect?

Show answer

Because both sides share the secret (exchanged once, at enrollment, via the QR/otpauth:// URI) and both compute the code independently from secret + floor(time/30) via HMAC. The server just recomputes its own code and compares. Nothing but the final 6 digits is ever typed, and even those are ephemeral. (§02.)

Q3. Your boss says "we added TOTP, so we're safe from phishing now." Correct them.

Show answer

TOTP defeats offline attacks (a leaked password alone is now useless) but not a real-time phishing proxy: a fake site can collect the password and the live TOTP code and replay both within 30 seconds. The phishing-proof factor is the origin-bound passkey (Lesson 9), because its signature can't be relayed to another origin. TOTP raises the bar; passkeys close the door. (§05.)

07 Where we go next

You can now layer factors and gate sensitive actions. Two practical gaps remain before you can ship a complete app. Lesson 11 covers the account lifecycle — the flows every real app needs and every tutorial skips: email verification, password reset, and account recovery, each riddled with subtle footguns (and each reusing the single-use-token machinery you already know). Then Lesson 12 is the capstone: a decision framework plus a complete, running Express auth app that combines everything.

📌 Remember this one line MFA = proofs of different types (know + have + are). TOTP = both sides derive the same 6-digit code from a shared secret + the clock, so nothing is transmitted to intercept — but it's still phishable in real time, unlike an origin-bound passkey. Use step-up to demand it only when the action warrants.

💬 Ask me anything — e.g. "wire TOTP enrollment with a real QR code in Express (otplib)", "design the backup-codes flow", or "when should we skip passwords entirely and go passkey-only?" · Back to Lesson 9 · Lesson 8 · Resources.