Skip to content

Chapter 8 of 204 min readBy Deepak GuptaPublished

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 across app., www., and login..
  • If you have several brand domains, WebAuthn Level 3 Related Origin Requests let you list them in https://example.com/.well-known/webauthn so one passkey works across them.
  • For native apps, publish Apple's apple-app-site-association (with webcredentials) and Android's assetlinks.json so 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:

  1. clientDataJSON.type is webauthn.create.
  2. challenge matches the one you stored, and you then delete it.
  3. origin is one of your allowed origins (exact match).
  4. rpIdHash in authenticator data equals SHA-256 of your RP ID.
  5. User Present flag is set; User Verified flag is set if you required it.
  6. The algorithm is one you offered.
  7. 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.create with mediation: "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-endpoints pointing to your enroll and manage pages so password managers can link users there.
  • Plan the "lost every device" case before launch (Chapter 12).
Warning

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.