auth · Lesson 9 · Node + Express

Passkeys: Phishing-Proof Login

Every method so far has one shared weakness: a secret the user can be tricked into handing over — a password, a code, a magic link. Passkeys end that. They swap "a secret you send" for "a private key that never leaves your device and signs a challenge bound to the real website." There's nothing to type, nothing to phish, nothing to replay. Today you'll generate a real key pair and watch a phishing attack fail by construction.

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

A passkey is a public/private key pair where the private key never leaves your device, and every login signs a fresh server challenge that is cryptographically bound to the website's origin. The server stores only the public key (nothing to steal), and a phishing site can't get a usable signature because the signature is stamped with its origin, not the real one. This is the first method in the course that is phishing-proof, not merely phishing-resistant.

01 The problem only passkeys solve

Recall Lesson 8's honest admission: magic links and OTP are still phishable, because a human can be tricked into relaying the secret to a fake site. The root cause is universal to every shared-secret method: if there's a secret the user can transmit, they can be fooled into transmitting it to the wrong party. Passwords, OTPs, even SMS — all share this. Passkeys break the pattern by never transmitting a secret at all.

💡 The stack: passkey = WebAuthn + FIDO2 "Passkey" is the friendly name. Under it: WebAuthn (the W3C browser API your JS calls), CTAP (how the browser talks to an authenticator — Touch ID, a phone, a YubiKey), together = FIDO2 (the FIDO Alliance standard). You'll usually touch only WebAuthn via a library. See FIDO Alliance — Passkeys · web.dev.

02 The core swap: public-key crypto (the mirror of Lesson 4)

Passwords and passkeys are opposites. With a password, you send the secret and the server stores a hash of it — so the server holds something worth stealing, and you hand the secret over every login. With a passkey, the secret (private key) never leaves your device, and the server stores only the matching public key — which is useless to a thief.

Password (Lesson 4)Passkey (this lesson)
What the server storesA salted hash of your secretYour public key (not a secret at all)
Value of a server breachCrackable hashes → some accounts fallNothing — public keys can't log in
What you send each loginThe secret itselfA signature over a one-time challenge
Reusable across sites?Users do (→ breach cascades)No — a unique key pair per site
Phishable?YesNo — origin-bound (§05)
💡 You already saw this signature idea "Sign something with a private key, verify with a public key" is RS256/ES256 from Lesson 3 — the same asymmetric crypto behind "Sign in with Google." Passkeys put that key pair in your pocket instead of Google's data center. The whole course's primitives, recombined one last time.

03 The two ceremonies

WebAuthn has exactly two flows. Both are a challenge the authenticator answers — the difference is whether you're creating the key pair or using it.

Authenticator (your device) Server (Relying Party) REGISTER (once): ① challenge + rpId make key pair · keep PRIVATE (Touch ID / phone unlock) ② PUBLIC key + credId → stored AUTHENTICATE (each login): ③ fresh random challenge SIGN(challenge + origin) with private key ④ signature → verify w/ public key ✓ → start a session (L2)
Registration creates and banks a public key once. Every login is a fresh challenge the device signs with the private key — and the same handoff to a session (Lesson 2) as every other method.

04 🎯 Your tangible win (5 min): generate a passkey, defeat a phisher

A zero-dependency lab runs the whole ceremony with a real P-256 key pair (the WebAuthn default), then stages a phishing attack and a replay attack against it.

# in your terminal (no npm install needed):
cd ~/projects/learn/public/courses/auth/practice/passkey-lab
node demo.js
🎯 The win — phishing failing by construction, verbatim
login on the REAL site → ✅ {"loggedIn":true}

🎣 user phished on https://app.evil.example — the device DOES sign, but:
  signed for origin: https://app.evil.example
  🛡️ origin mismatch: signed for "app.evil.example", expected "app.example"

🔁 attacker replays a captured valid assertion:
  🛡️ stale/replayed challenge
This is the payoff of the whole course. The phisher did everything right — fooled the user, relayed a real challenge, even got a real signature — and still failed, because the signature is bound to the origin the browser actually displayed. No human mistake can leak a reusable secret, because there is no secret to leak. Open demo.js: the anti-phishing magic is one line — assertion.origin !== expectedOrigin.

05 Why phishing is impossible here (the two guarantees)

① Origin binding

The browser — not the page — stamps the real origin into what's signed. A passkey for app.example won't even be offered on evil.example, and any signature it did make would carry evil.example and be rejected by the real server. The user can't hand it to the wrong site.

② Nothing to replay

Each login signs a fresh single-use challenge. A captured signature is worthless — the server already consumed that challenge and demands a new one. (Same single-use idea as Lessons 5 & 8, now on a signature.)

💡 Compare the whole course on one axis: phishability Password → phishable. OTP / magic link → phishable (human relays it). "Sign in with Google" → as phishable as Google's own login. Passkey → unphishable, because the secret never travels and the assertion is origin-bound. That's why every major platform is pushing passkeys as the default. See SimpleWebAuthn — passkeys.

06 In practice: don't hand-roll it

The lab simplifies (real WebAuthn signs authenticatorData + a hash of clientDataJSON, with attestation, signature counters, and CBOR encoding). You should never implement the byte-level verification yourself — use a vetted library. In Node that's @simplewebauthn/server, and the shape is small:

const { generateRegistrationOptions, verifyRegistrationResponse,
        generateAuthenticationOptions, verifyAuthenticationResponse } = require('@simplewebauthn/server');

// REGISTER — server issues options (incl. a challenge); browser calls navigator.credentials.create()
app.post('/register/options', async (req, res) => {
  const options = await generateRegistrationOptions({
    rpName: 'MyApp', rpID: 'app.example', userName: req.user.email });
  req.session.challenge = options.challenge;             // remember it to verify next
  res.json(options);
});
app.post('/register/verify', async (req, res) => {
  const v = await verifyRegistrationResponse({ response: req.body,
    expectedChallenge: req.session.challenge,
    expectedOrigin: 'https://app.example', expectedRPID: 'app.example' });
  if (v.verified) await db.saveCredential(req.user.id, v.registrationInfo); // store PUBLIC key + credId
  res.json({ ok: v.verified });
});
// LOGIN mirrors this: generate/verifyAuthenticationResponse, then req.session.userId = user.id  ← the handoff
⚠️ Real-world gaps to plan for Passkeys need a fallback (not every device has one yet) and account recovery (lost-device flow) — usually a second passkey or a one-time recovery method. And note the modern reality: most consumer passkeys are synced (iCloud Keychain, Google Password Manager) across your devices, which trades a little "private key never leaves hardware" purity for usability. Design for sync + recovery from day one. See passkeys.dev.

07 Check yourself

Q1. A passkey server database leaks completely. How worried should you be, compared to a password database leaking?

Show answer

Far less. The server stores only public keys, which by design reveal nothing and can't be used to authenticate — there's nothing to crack, no rainbow tables, no reuse cascade. A leaked password database (even hashed) still risks weak passwords being cracked and reused elsewhere. "Nothing secret on the server" is a core passkey advantage. (§02.)

Q2. A user is successfully tricked into visiting a pixel-perfect phishing clone and approves the passkey prompt. Why doesn't the attacker get in?

Show answer

Origin binding. The browser stamps the actual origin (app.evil.example) into the signed data — the page can't forge it — and typically the authenticator won't even offer a passkey registered to app.example on a different origin. Any signature produced carries the wrong origin, so the real server rejects it. The user's approval can't leak a usable credential because nothing reusable is transmitted. (§04–05.)

Q3. Why is each authentication a signature over a fresh server-issued challenge, rather than something static?

Show answer

Replay protection. A static signed value could be captured and re-sent. A fresh single-use challenge means a captured assertion is dead on arrival — the server already consumed that challenge and requires a new one (you saw "stale/replayed challenge"). Same single-use principle as the OAuth code (L5) and magic links (L8), applied to a signature. (§05.)

08 Where we go next — and how far you've come

That's the modern endgame: a login with no shared secret, immune to phishing and replay, leaking nothing on breach. Step back and look at the whole map you've built — you can now place any auth system on it:

Stay logged in (L1–3, 7)

Sessions vs tokens · cookies & CSRF · JWTs · refresh rotation & revocation.

Log in (L4–6, 8–9)

Passwords · OAuth2 · PKCE+OIDC · magic links/OTP · passkeys.

The spine

Every method ends at req.session.userId = … — phase ① hands off to phase ③.

Recurring primitives

Hashing, signing, single-use + expiry, origin/secret binding — reused everywhere.

Lesson 10 is the capstone: a decision framework — given a real app, which methods do you combine, where does each token live, and how do you wire it in Express end to end — turning this map into a build you can ship.

📌 Remember this one line A passkey is a per-site key pair whose private half never leaves your device; login = signing a fresh challenge bound to the real origin. The server stores only the public key, so breaches leak nothing and phishing fails by construction — the secret never travels.

💬 Ask me anything — e.g. "stand up a real @simplewebauthn demo I can use with Touch ID", "how do synced vs device-bound passkeys differ?", or "design the recovery flow." · Back to Lesson 8 · Lesson 3 · Resources.