01 · Foundation
Why Passwords Lost
Passwords fail for one structural reason: they are shared secrets. The same string that proves who you are must be typed into a page, sent over the wire, and stored (hashed, hopefully) on a server. Every one of those hops is an attack surface, and forty years of breaches say the attackers are winning.
🎣
Phishing
A convincing clone page harvests the secret because humans cannot reliably verify a URL under pressure. MFA codes get phished too.
🔁
Reuse
One leaked password unlocks a dozen accounts. Credential-stuffing bots turn a breach anywhere into a breach everywhere.
🗄️
Database breaches
A server-side dump hands attackers hashes to crack offline at GPU speed. Your users pay for someone else's ops mistake.
Shared Secret vs Keypair
Password
Same secret typed on any page
Phishable — humans verify URLs
Server stores a crackable hash
Reused across sites by default
Needs bolt-on MFA to be safe
Passkey
Private key never leaves device
Browser enforces origin match
Server stores only a public key
Unique keypair per site, always
Possession + biometric built in
The real shift
Passkeys do not make passwords stronger — they remove the shared secret entirely. There is nothing to type, nothing to reuse, and nothing worth stealing from the server. The breach economics that made password dumps valuable simply stop applying.
01 · Foundation
What is a Passkey?
A passkey is a FIDO2 public-key credential: a unique keypair minted per site at registration. The private key lives in your device's secure hardware (or a synced credential vault) and signs things; the server keeps only the public key and verifies signatures. Neither half is a secret you can leak by typing it.
Signing in is a challenge–response: the server sends random bytes, the authenticator signs them after a local Face ID, fingerprint, or PIN check, and the server verifies the signature with the stored public key. The challenge is single-use, so a recorded response is worthless a second time.
The Standards Stack
WebAuthn — browser APICTAP2 — browser ↔ authenticatorFIDO2 — the umbrella specpasskey — discoverable credential
"Passkey" is the consumer name for a discoverable WebAuthn credential — one the authenticator can find by site alone (residentKey), so the user never types a username first. Everything in this guide flows through two browser calls: navigator.credentials.create() to mint a keypair and navigator.credentials.get() to sign a challenge.
Mental model
Think of a passkey as an SSH keypair your grandmother can use: the same asymmetric-crypto guarantees, but minting, storage, unlock, and sync are all handled by the platform behind a fingerprint.
01 · Foundation
The Trust Model
The property that makes passkeys different from every prior MFA scheme is origin binding. Each credential is scoped to a relying party ID (rpId) — your domain — and the browser, not the user, enforces the match. A passkey registered for example.com is invisible to every other origin.
This is why phishing dies. A fake site can clone your pixels perfectly, but it runs on the wrong origin, so the browser refuses to even surface the credential. There is no secret for the user to be tricked into typing — the human is no longer the verifier, the user agent is.
Who Checks What
🌐
Browser
Enforces rpId-to-origin scoping and records the true origin into clientDataJSON — the page cannot forge it.
📱
Authenticator
Guards the private key and requires user verification — biometric or PIN — before producing any signature.
🖥️
Server
Verifies the signature, the challenge, the origin, and the rpIdHash on every single assertion. Trust, then verify anyway.
Scope carefully
The
rpId must be your site's registrable domain (or a parent of it) and it is forever. Register credentials under
app.example.com and they will not work on
example.com. Most teams should set rpId to the apex domain on day one.
02 · Ceremonies
The Registration Ceremony
WebAuthn calls its flows ceremonies — fixed sequences where browser, authenticator, and server each play a role. Registration mints the keypair: the server issues options and a challenge, the browser hands them to the authenticator, the user approves locally, and the new public key travels back for storage.
The Client Call
register.ts
// 1. Fetch creation options from your server (challenge is minted there)
const options = await fetch('/api/register/options').then((r) => r.json());
const credential = (await navigator.credentials.create({
publicKey: {
challenge: base64urlToBuffer(options.challenge), // random, single-use
rp: { id: 'example.com', name: 'Example' }, // relying party
user: {
id: base64urlToBuffer(options.userId), // stable opaque id
name: 'tyler@example.com',
displayName: 'Tyler',
},
pubKeyCredParams: [
{ type: 'public-key', alg: -7 }, // ES256 — preferred
{ type: 'public-key', alg: -257 }, // RS256 — fallback
],
authenticatorSelection: {
residentKey: 'required', // discoverable = a real passkey
userVerification: 'preferred', // Face ID / fingerprint / PIN
},
},
})) as PublicKeyCredential;
// 2. Send the attestation back; the server stores the PUBLIC key only
await fetch('/api/register/verify', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(credential.toJSON()),
});
Three fields do most of the work: rp.id scopes the credential to your domain forever, user.id is the stable opaque handle the credential maps back to, and residentKey: 'required' makes it discoverable — which is what upgrades a WebAuthn credential into a passkey.
Challenge discipline
Challenges must be generated
server-side, be cryptographically random, and be single-use. A client-generated or reused challenge silently converts your unphishable login into a replayable one.
02 · Ceremonies
The Authentication Ceremony
Authentication is the same dance with credentials.get(): the server mints a fresh challenge, the authenticator signs it with the private key after user verification, and the browser returns an assertion — the signed proof of possession.
authenticate.ts
// Same shape as registration, but .get() — the authenticator SIGNS
const options = await fetch('/api/login/options').then((r) => r.json());
const assertion = (await navigator.credentials.get({
publicKey: {
challenge: base64urlToBuffer(options.challenge), // fresh per attempt
rpId: 'example.com',
allowCredentials: [], // empty list = any discoverable passkey
userVerification: 'preferred',
},
})) as PublicKeyCredential;
// The assertion bundles three things the server will verify:
// clientDataJSON — origin + challenge the browser observed
// authenticatorData — rpIdHash, flags, signature counter
// signature — private key's signature over both
await fetch('/api/login/verify', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(assertion.toJSON()),
});
Leaving allowCredentials empty is the passkey move: the authenticator lists whatever discoverable credentials it holds for this rpId and the user just picks an account — no username field, no account enumeration surface.
What Rides in the Assertion
clientDataJSON — origin + challengeauthenticatorData — rpIdHash + flagssignature — over both blobs
User verification flag
The authenticator data carries a
UV bit stating whether a biometric or PIN actually happened. If your policy requires verified users (it should for anything sensitive), check that flag on the server — do not assume it from the client options.
02 · Ceremonies
Server Verification
Everything the browser sends is attacker-controllable input until proven otherwise. The server's job is a fixed gauntlet of checks, and the good news is you should not hand-roll it — use a maintained library like @simplewebauthn/server and feed it the expectations.
verify-assertion.ts
import { verifyAuthenticationResponse } from '@simplewebauthn/server';
const verification = await verifyAuthenticationResponse({
response: body, // assertion from the browser
expectedChallenge: session.challenge, // must be one YOU minted
expectedOrigin: 'https://example.com', // checked vs clientDataJSON
expectedRPID: 'example.com', // checked vs rpIdHash
credential: {
id: stored.credentialId,
publicKey: stored.publicKey, // captured at registration
counter: stored.counter, // clone-detection signal
},
});
if (!verification.verified) {
throw new Error('Assertion rejected');
}
// Persist the new counter. A counter that moves BACKWARDS means a
// second copy of a device-bound key may exist — treat as suspect.
await db.credentials.update(stored.id, {
counter: verification.authenticationInfo.newCounter,
});
The Four Checks That Matter
🌐
Origin
clientDataJSON.origin must equal your HTTPS origin exactly — this is where phishing attempts die server-side.
#️⃣
rpIdHash
authenticatorData starts with sha256(rpId). Wrong hash means the assertion was minted for a different party.
✍️
Signature
Verified against the public key stored at registration, over authenticatorData plus the clientDataJSON hash.
🔢
Counter
Must not regress. A backwards counter suggests a cloned device-bound key. Synced passkeys often report zero — allow it.
And the challenge
Bind each issued challenge to the session that requested it, expire it in minutes, and delete it on first use. Signature verification is pointless if the thing being signed can be replayed.
03 · In Practice
Sync & Recovery
The original WebAuthn story had a hole: keys locked to one device mean a lost phone is a lost account. Synced passkeys fixed adoption by letting the credential vault — iCloud Keychain, Google Password Manager, 1Password — replicate private keys end-to-end encrypted across a user's devices.
Picking a Side
Synced (consumer default)
Survives device loss
Works across a user's devices
Zero-friction enrollment
Trust shifts to vault account
Device-bound (high assurance)
Key provably in one place
Attestation can be enforced
Right for admin / finance roles
You must plan for loss upfront
The tradeoff is honest: syncing moves the root of trust from the device's secure enclave to the user's cloud account. For consumer apps that trade is overwhelmingly worth it — account recovery, not exotic key theft, is where real users actually get hurt.
Recovery is the real design problem
Encourage
multiple passkeys per account— phone plus laptop plus a hardware key — and ship a management UI where users can list, rename, and revoke them. If your only recovery path is "email a password reset link", the password is still your weakest link wearing a new outfit.
03 · In Practice
Conditional UI
The best passkey UX is the one users already know: autofill. Conditional mediation arms a silent, pending credentials.get() when the page loads. Nothing appears until the user focuses the username field — then their passkeys show up in the same dropdown as saved passwords, and one tap signs them in.
conditional-ui.ts
// Arm a pending request on page load; it resolves only if the user
// picks a passkey from the autofill dropdown on the username field.
if (
window.PublicKeyCredential &&
(await PublicKeyCredential.isConditionalMediationAvailable())
) {
const options = await fetch('/api/login/options').then((r) => r.json());
const assertion = await navigator.credentials.get({
mediation: 'conditional', // no modal until user opts in
publicKey: {
challenge: base64urlToBuffer(options.challenge),
rpId: 'example.com',
},
});
await submitAssertion(assertion); // signed in from the autofill
}
// The input only needs the autofill token appended:
// <input type="text" autoComplete="username webauthn" />
Why This Beats a Button
🤫
No modal ambush
Users without passkeys see a normal login form. Users with them get a one-tap path. Nobody gets a surprise dialog.
🧠
Zero new habits
Autofill is a decade-old muscle memory. Passkeys ride it instead of teaching a new "Sign in with passkey" ritual.
📈
Adoption flywheel
Every successful autofill sign-in reinforces the passkey as the default path, letting the password quietly fade.
Small print
Feature-detect with
isConditionalMediationAvailable(), keep the request pending for the whole page lifetime, and abort it with an
AbortController before starting any modal WebAuthn call — one pending request per page is the rule.
03 · In Practice
Rollout Strategy
Nobody flips a user base to passkeys in one release. The winning pattern everywhere — Google, GitHub, Shopify — is the same three phases: add alongside, promote to default, retire last.
➕
Coexist
Passkeys added, passwords untouched
📣
Promote
Upgrade prompt after each sign-in
⭐
Default
Conditional UI leads, password behind
🪦
Retire
Passwordless for opted-in accounts
Prompt at the moment of proof
The highest-converting upgrade prompt fires
immediately after a successful password sign-in — identity is freshly verified, the user is mid-task, and enrollment is one biometric tap. Interstitials at random moments convert an order of magnitude worse.
Instrument the funnel
Track enrollment rate, passkey-vs-password sign-in share, and ceremony error codes from day one.
NotAllowedError is your catch-all signal for users cancelling — a spike there is a UX bug, not an attack.
Retire passwords last
As long as a password (or password-reset email) can still access an account, your security floor is unchanged — passkeys are pure UX until the fallback dies. Kill the password only for users with multiple registered passkeys and a verified recovery path.
04 · Execution
Ship Checklist
The gap between a passkey demo and a production rollout is this list. Work it top to bottom — setup, ceremonies, verification, then the human parts.
Ship Checklist · 0/10
Serve everything over HTTPS with a stable rpId
setupMint challenges server-side, random and single-use
setupRegistration ceremony with residentKey: required
ceremonyAuthentication ceremony with empty allowCredentials
ceremonyVerify origin, rpIdHash, and signature on the server
verifyStore and compare the signature counter per credential
verifyConditional UI on the sign-in form (username webauthn)
uxPrompt passkey upgrade right after password sign-in
rolloutPasskey management UI — list, rename, revoke
uxRecovery path that is not just the password again
rolloutWhere to start
If you adopt only three: mint single-use challenges server-side, verify origin and rpIdHash on every assertion, and prompt for passkey enrollment right after password sign-in. Those three carry most of the security and most of the adoption.