The idea: a key pair per site instead of a shared password

A password is a shared secret: you and the site both know it, so anyone who learns it, from a phishing page, a leaked database or a reused password, can log in as you. A passkey replaces it with a key pair made just for that site. The private key stays in your authenticator (a phone, a laptop's secure chip or a security key); the site stores only the public key. To log in you prove you hold the private key by signing a fresh random challenge. The standard behind it is WebAuthn (in the browser) together with FIDO2/CTAP (between the browser and the authenticator).

Two columns. Password: the server stores a password hash, a phishing site receives the password and can reuse it, a stolen database gives hashes to crack offline. Passkey: the server stores a public key, a phishing site gets nothing usable because the key is bound to example.com, a stolen database gives only public keys that cannot sign.
With a password the secret travels and is stored on both sides; with a passkey the secret never leaves the authenticator.

The canvas has four regions. On the left Alice's authenticator with one row per site it holds a key for. In the middle the browser, with the address bar and the clientDataJSON it writes, and under it an attacker panel with whatever the attacker has captured or stolen. On the right the relying party (the website, example.com) with Alice's row and its list of challenges. Under them, the bytes the authenticator signs, and the server's checks in order. The hex values are short stand-ins (8 characters), not real SHA-256 or ECDSA output.

Registration: navigator.credentials.create()

Alice is already signed in (with her password) and clicks "Create a passkey". The server sends options: a random challenge, rp.id: "example.com", a user.id and the algorithms it accepts (ES256, ECDSA on P-256, COSE id −7). The browser checks that rp.id is the page's own domain and passes the request to the authenticator. The authenticator asks Alice to confirm (fingerprint, face or PIN), creates a new key pair for this rpId only, and returns the credential ID, the public key and authenticatorData. The server stores the credential ID and the public key in Alice's row (Demo 1).

Signing in: navigator.credentials.get()

The server sends a new challenge. The browser builds clientDataJSON itself:

{ "type": "webauthn.get", "challenge": "…", "origin": "https://example.com" }

The authenticator finds the key for the rpId, asks Alice to confirm, increases its signCount (if it keeps one) and signs authenticatorData ‖ SHA-256(clientDataJSON). authenticatorData holds the rpIdHash (SHA-256 of example.com), the flags (UP user present, UV user verified) and the signCount. The server verifies the signature with the public key it stored (Demo 2).

authenticatorData (rpIdHash, flags UP and UV, signCount) is joined with the SHA-256 hash of clientDataJSON (type, challenge, origin); the private key signs the result. The server rebuilds the same bytes and checks the signature with the stored public key.
The signature covers the site (rpIdHash), the challenge and the origin, so it is worth nothing anywhere else or a second time.

Why phishing fails

A phishing page on examp1e.com cannot get a usable signature, for three separate reasons:

  • The browser refuses an rpId that is not the page's own domain or a parent of it, so examp1e.com cannot ask for example.com's passkey (Demo 3, first try).
  • If it asks for its own rpId, the authenticator has no key for examp1e.com: a key is never used for another site (Demo 3, second try).
  • Even where the browser allows the rpId, as on a subdomain the attacker controls, the browser writes the real origin into clientDataJSON, and the signature covers it. The server sees https://uploads.example.com and rejects it (Demo 4).

Alice doesn't need to notice the wrong address: the check is done by software, not by her eyes. That is the difference from one-time codes, which she can type into the phishing page just as easily as the password.

Time runs downward. A phishing page on examp1e.com asks the browser for a passkey with rpId example.com and gets a SecurityError. Asking with its own rpId examp1e.com, the authenticator has no key for it. On the attacker's subdomain uploads.example.com the browser allows rpId example.com, but the signed clientDataJSON says origin https://uploads.example.com, so example.com rejects the login at the origin check.
Three layers stop a phishing site: the browser's rpId rule, one key per site, and the origin inside the signed data.

The server's checks

The server checks the response in this order and rejects it at the first failure. Each attack demo fails at exactly one of them.

CheckWhat it stopsDemo
type is webauthn.geta registration response used as a login–
challenge is one the server issued and hasn't usedreplaying a recorded login5
origin is https://example.comphishing pages, the attacker's subdomain4
rpIdHash is SHA-256(example.com)a key made for another site–
UP flag seta signature with nobody at the device–
UV flag set, if requireda stolen phone or key used without the PIN or fingerprint7
signCount greater than storeda cloned hardware key8
signature verifies with the stored public keychanged data, forged responses, a stolen database6, 9

User verification and the signature counter

UP (user present) means someone touched or tapped the authenticator. UV (user verified) means the authenticator also checked who: a PIN, a fingerprint or a face. A site that wants a passkey to replace both the password and a second factor asks for userVerification: "required"; then a login with only a touch is refused (Demo 7). Changing the flag on the way doesn't help: the flags are inside the signed bytes (Demo 6).

A security key keeps its private key in hardware and counts its signatures. If an attacker ever copied the key, the copy and the original would both report counts, and sooner or later the server sees a count that does not go up: it disables the credential (Demo 8). Synced passkeys (iCloud Keychain, Google Password Manager, password managers) are copied between your devices on purpose, so they always report signCount 0 and this check is skipped; their safety rests on the sync provider's account security.

Passkeys, passwords and one-time codes

PasswordPassword + SMS / TOTP codePasskey
Phishing page getsthe passwordthe password and a code it can use at oncenothing usable
Recorded login replayedworkscode expiredchallenge used
Server database stolenhashes to crack (see Salt vs Pepper)hashes + TOTP seedspublic keys only (Demo 9)
Same secret on many sitesoftenoftennever: one key pair per site

See How a Digital Signature Works for signing and verifying step by step, Symmetric vs Asymmetric Encryption for public and private keys, and OWASP A07 Authentication Failures for the attacks on password logins. After a passkey login the site still keeps you signed in with a session (cookies, JWT vs session).

What the page leaves out

  • Attestation formats (packed, TPM, Apple) and how a site can require a certified authenticator.
  • Signing in on a laptop with a phone by scanning a QR code (the "hybrid" transport), and Bluetooth/NFC/USB details.
  • Discoverable credentials and passkey autofill (conditional UI), which let the site skip the username.
  • Account recovery when every device is lost, and registering more than one passkey per account.
  • The CBOR and COSE encodings, base64url fields, and the real ECDSA arithmetic (the hex values here are short stand-ins).