Passkeys, Step by Step
Passkeys are WebAuthn credentials, and WebAuthn Level 3 became a W3C Recommendation on August 25, 2026. It standardizes conditional mediation for autofill and upgrades, backup flags, related origins, and the Signals API. (W3C, Start with Identity) Build on a maintained WebAuthn server library for your language; your job is the ceremony logic, storage, and UX, not CBOR parsing.
Decision 1: your RP ID
The relying party ID is the domain passkeys are bound to. Choose it once, because changing it orphans every passkey.
- Use your registrable domain (
example.com), not a subdomain (login.example.com), so passkeys work acrossapp.,www., andlogin.. - If you have several brand domains, WebAuthn Level 3 Related Origin Requests let you list them in
https://example.com/.well-known/webauthnso one passkey works across them. - For native apps, publish Apple's
apple-app-site-association(withwebcredentials) and Android'sassetlinks.jsonso apps share the same passkeys as your website.
Registration ceremony
Server: create options.
{
"rp": { "id": "example.com", "name": "Example" },
"user": { "id": "<random 32 bytes, base64url>", "name": "ana@example.com", "displayName": "Ana" },
"challenge": "<random 32 bytes, base64url>",
"pubKeyCredParams": [ { "type": "public-key", "alg": -7 }, { "type": "public-key", "alg": -257 } ],
"excludeCredentials": [ /* user's existing credential IDs */ ],
"authenticatorSelection": { "residentKey": "required", "userVerification": "preferred" },
"attestation": "none",
"timeout": 300000
}
Rules: user.id is a random handle, never an email or your database ID. Store the challenge server-side, bound to the session, single use, short-lived. Use excludeCredentials so users do not register the same authenticator twice. Request attestation only if you have a real policy that needs it, such as an enterprise allowlist of hardware keys.
Browser: navigator.credentials.create({ publicKey: options }).
Server: verify the response. Your library does the parsing; make sure it checks all of these:
clientDataJSON.typeiswebauthn.create.challengematches the one you stored, and you then delete it.originis one of your allowed origins (exact match).rpIdHashin authenticator data equals SHA-256 of your RP ID.- User Present flag is set; User Verified flag is set if you required it.
- The algorithm is one you offered.
- The credential ID is not already registered to another user.
Then store the credential ID, public key, sign count, AAGUID, transports, and the Backup Eligible and Backup State flags (Chapter 3 schema). Name the passkey for the user using the AAGUID's provider, for example "iCloud Keychain" or "Google Password Manager."
Authentication ceremony
Server options: a fresh challenge, your rpId, userVerification: "preferred", and an empty allowCredentials list so discoverable passkeys can sign in without a username.
Browser, with autofill:
const cred = await navigator.credentials.get({
mediation: "conditional",
publicKey: options
});
Pair this with autocomplete="username webauthn" on the identifier field. Start the conditional request on page load. Saved passkeys then appear in the same autofill menu as passwords.
Server verification: check type webauthn.get, challenge, origin, RP ID hash, and flags as above. Look up the credential by ID, find the user by userHandle, and verify the signature over authenticatorData || SHA-256(clientDataJSON) with the stored public key.
Sign counter: synced passkeys usually report 0. Only treat a counter that goes backward from a non-zero value as a cloning signal, and log it rather than hard-failing.
Upgrading existing users
- After sign-in: prompt to create a passkey when the user has just proven who they are.
- Conditional create: after a password-autofill sign-in, call
navigator.credentials.createwithmediation: "conditional"so supporting browsers create a passkey silently. (Chrome for Developers) - After recovery and password reset: the most receptive moment you will get.
Lifecycle
- Let users list, rename, and delete passkeys. Deletion needs step-up and a notification.
- Use the Signals API to tell password managers when a credential was deleted or a user's name changed, so stale passkeys stop appearing.
- Publish
/.well-known/passkey-endpointspointing to your enroll and manage pages so password managers can link users there. - Plan the "lost every device" case before launch (Chapter 12).
Common mistakes
- RP ID set to a subdomain, then discovered too late.
- Email address used as
user.id. - Challenges reused or not bound to the session.
- Hiding the passkey option behind a password form, which keeps usage low even when enrollment is high.
Key takeaways
- Choose the RP ID once, at your registrable domain; changing it orphans every passkey.
- Use a random user handle, single-use server-side challenges, and excludeCredentials.
- Turn on passkey autofill with conditional mediation and autocomplete="username webauthn".
- Ask for a passkey right after sign-in, after recovery, and through conditional create.