~/$PASSKEYS//AuthPasskeys & WebAuthn · v1.0
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.

PRIVATE KEYnever leavesthe deviceauthenticatorPUBLIC KEYuseless toa thiefserverchallenge (random bytes)signature over challengenothing reusable ever crosses the wire
One keypair per site: the private half signs, the public half verifies

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.

example.comreal sign-in pagerpId matches origin✓ passkey offeredbrowser signs for example.comexamp1e-login.iopixel-perfect clonerpId does not match✗ no credential existsthere is nothing to phishthe browser scopes every passkey to its registered rpId
Origin binding: the clone site cannot even ask for the real credential

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.

browserauthenticatorserver1 · creation options + challenge2 · credentials.create()user verifies: Face ID / PIN3 · new keypair + attestation4 · verify + store public keythe private key is minted in step 3 and never appears again
Registration ceremony: four hops, one keypair, zero shared secrets

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.

DEVICE-BOUNDkey ×1lose it, lose the keysecurity keys / TPMSYNCEDvaultphonelaptoptabletiCloud Keychain / Google PM / 1Passwordvs
Device-bound keys maximize assurance; synced keys solve recovery

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
setup
Mint challenges server-side, random and single-use
setup
Registration ceremony with residentKey: required
ceremony
Authentication ceremony with empty allowCredentials
ceremony
Verify origin, rpIdHash, and signature on the server
verify
Store and compare the signature counter per credential
verify
Conditional UI on the sign-in form (username webauthn)
ux
Prompt passkey upgrade right after password sign-in
rollout
Passkey management UI — list, rename, revoke
ux
Recovery path that is not just the password again
rollout
Where 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.
Passkeys Reference
webauthn · fido2 · ceremonies
experiments.tyler.lab