The Magic Behind Passkeys
Passkeys are explained badly almost everywhere. The usual description — "you log in with your face instead of a password" — isn't merely imprecise, it describes something that isn't happening. Your face is not sent anywhere. It isn't the credential. It's barely part of the protocol.
Passkeys are explained badly almost everywhere. The usual description — "you log in with your face instead of a password" — isn't merely imprecise, it describes something that isn't happening. Your face is not sent anywhere. It isn't the credential. It's barely part of the protocol.
What's actually happening is a challenge-response signature scheme with an origin check enforced by the browser, and it's simple enough to hold in your head completely. This is that explanation: the mechanism, the parts that get misdescribed, and where the design has genuine sharp edges.
The credential is a keypair, one per site
When you register a passkey, the authenticator — Secure Enclave on a Mac, a TPM-backed keystore on Windows, the Titan chip on a Pixel, a YubiKey, or a software authenticator inside a password manager — generates a fresh asymmetric keypair. Usually ES256 (ECDSA over P-256), sometimes Ed25519 or RS256.
The private key stays inside the authenticator. The public key goes to your server along with a credential ID, an opaque handle the server hands back later to say "use that key."
Three properties matter, and all three are structural rather than policy:
- One keypair per relying party. The RP ID is a domain (
example.com). A credential minted for one RP is unusable at another, so there's no cross-site correlation and no credential-stuffing equivalent. - The private key is not exportable through the WebAuthn API. No method returns it. (Whether the platform can move it elsewhere is the synced-passkey question, below.)
- The server stores nothing secret. A public key and an opaque ID. A dump of that table is uninteresting.
The registration ceremony
The spec says "ceremonies" rather than "flows" — fussy, but useful: a ceremony includes the human and the user agent as participants with real responsibilities.
sequenceDiagram
participant U as User
participant A as Authenticator
participant B as Browser
participant S as Server (RP)
S->>B: challenge, rpId, user handle, pubKeyCredParams
B->>B: build clientDataJSON<br/>(challenge + real origin + type)
B->>A: SHA-256(clientDataJSON), rpIdHash, options
A->>U: verify user (biometric / PIN)
U-->>A: local gesture
A->>A: generate keypair, store private key
A->>B: credentialId, public key,<br/>authenticatorData, attestation
B->>S: attestationObject + clientDataJSON
S->>S: verify challenge, origin, rpIdHash;<br/>store public key + credentialId + signCount
The subtlety worth catching: the authenticator never sees the origin. The browser builds clientDataJSON containing the challenge, the actual origin it is talking to, and the ceremony type, then passes only a hash of it down. The authenticator receives rpIdHash and blindly signs what it's given. Origin honesty is enforced by the user agent, not the hardware — which is why the browser's refusal to let evil.com request credentials for example.com is the load-bearing part of the phishing story.
Attestation is the part most teams take too seriously. It's an optional, manufacturer-signed statement about the authenticator's make and model. Genuinely useful if you must assert "only FIDO-certified hardware from this vendor." For consumer sign-up it's a privacy liability and an interop tax, and the right value is usually attestation: "none". Most large deployments never verify it.
The authentication ceremony
sequenceDiagram
participant A as Authenticator
participant B as Browser
participant S as Server (RP)
S->>B: challenge, rpId, allowCredentials[]
B->>B: clientDataJSON = {challenge, origin, type:"webauthn.get"}
B->>A: SHA-256(clientDataJSON), rpIdHash
A->>A: match rpIdHash to a stored credential<br/>verify user, increment signCount
A->>B: signature over<br/>authenticatorData ‖ SHA-256(clientDataJSON)
B->>S: credentialId, authenticatorData,<br/>clientDataJSON, signature
S->>S: verify signature with stored public key<br/>check challenge, origin, rpIdHash, flags
The signed message is worth memorizing, because every WebAuthn bug I've seen at the server side comes from getting it wrong:
signature = Sign(privKey, authenticatorData || SHA-256(clientDataJSON))
authenticatorData carries the RP ID hash, a flags byte, and the signature counter. clientDataJSON carries the challenge and the origin. One signature therefore attests simultaneously: this challenge, this origin, this RP, user present, user verified, counter value. Skip any of those checks server-side and you've quietly removed a protection.
The flags deserve attention. UP (user present) means someone touched the thing. UV (user verified) means a biometric or PIN succeeded locally. Different guarantees — and if your policy is "passkeys count as two factors," you must require UV=1 and reject assertions without it. A surprising number of implementations set userVerification: "preferred", never check the flag, and believe they've deployed MFA.
The biggest misconception: your fingerprint is not the credential
Almost everyone outside the field believes the opposite, and a fair number inside it are fuzzy on the details.
No biometric data is transmitted. Ever. There is no field in the WebAuthn protocol that can carry it. Your server does not store a fingerprint, does not receive a face template, and could not obtain one if it wanted to. The entire biometric interaction happens between the user and a chip on their own device, and it terminates there.
What the biometric does is unlock local access to the private key. The template lives in a secure element — Secure Enclave, TPM, StrongBox — and never leaves it. The comparison happens inside that element, and its only output is a boolean. If yes, the key becomes usable for one signing operation and the authenticator sets UV to 1.
So the server learns exactly one biometric-adjacent fact: the authenticator says a local user-verification step succeeded. Not which finger. Not whose face. Not a score. One bit.
That reframes the risk model correctly. A breach of a passkey relying party cannot leak biometrics, because it never had any. The biometric is a local unlock gesture — architecturally closer to a lock screen than to a password. And it's substitutable: the device PIN produces the same UV flag. The protocol does not care how you convinced your own device to cooperate.
Origin binding, mechanically
The RP ID must be a registrable-domain suffix of the calling origin. login.example.com may claim example.com; evil.com may not, and neither may example.com.evil.com. The browser enforces this before the authenticator is invoked at all.
The origin then goes inside clientDataJSON and is covered by the signature, so the server independently confirms which origin the ceremony ran against. Two checks: the browser won't produce a signature for the wrong origin, and the server would detect it if something somehow did.
Phishing therefore fails at the protocol layer rather than at the user's judgment. A cloned login page cannot obtain a usable assertion, and the user cannot override that — there is no field into which a secret could be typed.
The signature counter, and why it's fading
authenticatorData includes a 32-bit counter that increments on each assertion. The server stores the last value and expects the next one to be higher.
Its purpose is narrow: detecting cloned authenticators. If a private key were extracted from a hardware token and both copies were in use, the counters diverge, and one of them eventually presents a value the server has already seen. A tripwire, not a prevention.
Synced passkeys largely retire it. A credential in iCloud Keychain or Google Password Manager is deliberately present on many devices, and a strictly monotonic shared counter isn't feasible. Those authenticators report 0 permanently, which the spec defines as "not supported." Non-zero counters that go backwards should raise a signal; zero should be accepted quietly. Rejecting all zero-counter assertions will break most of your users.
Discoverable credentials and usernameless login
A non-discoverable credential requires the server to name the credential — it sends allowCredentials with the IDs for a known user. So the user must identify themselves first: username, then passkey.
Here's the mechanism most people miss. With non-discoverable credentials, the authenticator frequently isn't storing anything at all. The credential ID is the private key, wrapped under a device-resident master key. The server unknowingly holds the key material on the authenticator's behalf, and the authenticator unwraps it on demand. That's how a hardware key with a few kilobytes of storage supports unlimited credentials.
A discoverable credential (formerly "resident key") is genuinely stored on the authenticator with the RP ID and user handle. Now it can answer "which accounts do you have for example.com?" with no hint, which is what makes usernameless and conditional-UI ("passkey autofill") login possible.
The cost is real slots. Hardware keys have a finite number — commonly 25 to 100 — and once full, registration fails. Platform and synced authenticators are effectively unbounded. The other side has a privacy cost too: sending allowCredentials for a typed username tells an unauthenticated caller whether that account exists.
Device-bound vs synced
The most important distinction introduced since 2022, and it changes the threat model, not just the convenience.
A device-bound passkey exists on exactly one authenticator. The private key is generated in hardware and cannot be exported. Lose the device, lose the credential. The original FIDO2 model, still the right choice for high-assurance workforce use.
A synced passkey lives in a credential manager — iCloud Keychain, Google Password Manager, 1Password — and replicates across the user's devices through that provider's end-to-end encrypted store.
Synced passkeys solved adoption. They also moved the security boundary: the credential is now protected by the cloud account's authentication, recovery, and escrow design. That's usually a large net improvement over passwords, and it is a meaningfully different claim from "the private key never leaves hardware." Make the accurate one.
The BE and BS flag bits in authenticatorData tell you which you're dealing with — backup-eligible and backup-state. If your policy needs device-bound credentials, check those bits at registration; don't assume.
Honest limits
Recovery is the real security boundary. Lose access to the credential manager account and the passkeys go with it. Whatever path you offer back — email link, support call, identity verification — determines the account's actual strength. It is entirely possible to run a phishing-resistant front door behind an email OTP recovery flow and to have achieved, in practice, email OTP security.
Enrollment is phishable. Registering a passkey requires proving who you are first, usually with something weaker. That bootstrap is a soft spot inside an otherwise hard system, and it's where attacker attention has migrated.
A synced passkey's ceiling is the cloud account. Compromise the Apple or Google account and the passkeys follow. Those accounts are well defended and they are also a concentrated, high-value target.
Shared and kiosk devices break the assumptions. A hospital workstation used by twelve people across three shifts has no personal authenticator to enroll. Cross-device authentication — QR code, completed on a phone, with BLE proximity checking — works and is genuinely phishing-resistant, but it costs seconds per login, and seconds per login matter enormously in exactly those environments.
The short version
A passkey is a per-site keypair whose private half stays in an authenticator, exercised by signing a server-supplied challenge together with the origin and a flags byte. The server keeps a public key and a credential ID, neither secret. Biometrics are a local unlock gesture producing a single bit — nothing biometric crosses the network or reaches your database. Phishing fails because the browser won't produce a signature for the wrong origin, not because the user noticed. And the hard problems moved rather than vanished: they live in enrollment, recovery, and whoever holds the sync account.