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 appA 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.
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.
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 stores | A salted hash of your secret | Your public key (not a secret at all) |
| Value of a server breach | Crackable hashes → some accounts fall | Nothing — public keys can't log in |
| What you send each login | The secret itself | A signature over a one-time challenge |
| Reusable across sites? | Users do (→ breach cascades) | No — a unique key pair per site |
| Phishable? | Yes | No — origin-bound (§05) |
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.
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
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 challengeThis 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.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.
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.)
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
Q1. A passkey server database leaks completely. How worried should you be, compared to a password database leaking?
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?
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?
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.)
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:
Sessions vs tokens · cookies & CSRF · JWTs · refresh rotation & revocation.
Passwords · OAuth2 · PKCE+OIDC · magic links/OTP · passkeys.
Every method ends at req.session.userId = … — phase ① hands off to phase ③.
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.
💬 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.