Headline: A passkey is a WebAuthn public-key credential held by the device's authenticator and permanently bound to one Relying Party ID (RP ID), and it replaces the password and the second factor in a single prompt. Three details caused nearly every bug I hit: the RP ID must be the origin's registrable domain or a parent of it, user.id must be an opaque handle rather than an email address, and the browser's autofill passkey chip only appears when you call navigator.credentials.get() with mediation: 'conditional'.
Key takeaways RP ID is permanent and one-directional. A passkey registered with rp.id: 'example.com' is usable from app.example.com. A passkey registered with rp.id: 'app.example.com' is never usable from example.com. Pick the apex domain before your first user registers. user.id is an opaque account handle, not an identifier. The WebAuthn spec caps it at 64 bytes and states it must not contain personally identifying information. An email address there is baked into the credential and cannot be rotated. Usernameless sign-in requires a discoverable credential, requested with authenticatorSelection.residentKey: 'required'. Without it, authentication must send an allowCredentials list, which means you need the username first. Conditional UI is a separate call. The passkey entry inside the browser's autofill dropdown needs autocomplete="username webauthn" on the input plus mediation: 'conditional'; a plain get() opens a blocking modal instead. Do not delete the password on day one. Passkeys move the account-recovery problem, they do not remove it.
A passkey is a WebAuthn credential whose private key never leaves the authenticator — a platform keychain such as iCloud Keychain, Google Password Manager, or Windows Hello, or a hardware security key — while the matching public key is stored on your server. Authentication is a signature: the server issues a random challenge, the authenticator signs it after a local user-verification gesture, and the server verifies the signature against the stored public key.
Because no shared secret ever crosses the network, there is nothing for a phishing site to capture. The stronger property is enforced by the browser, not by the user: the browser will only surface a credential whose RP ID matches the current origin's registrable domain, so a lookalike domain cannot get the authenticator to sign at all. That is the part TOTP never solved — a one-time code can be relayed to an attacker in real time, and a passkey signature cannot.
What a passkey replaces is the password plus the second factor, collapsed into one prompt. What it does not replace: session management, authorization, rate limiting, and account recovery.
Registration is two route handlers: one that generates options and stores the challenge server-side, and one that verifies the attestation and persists the credential. I use @simplewebauthn/server v13 rather than hand-rolling CBOR parsing.
excludeCredentials is the field people skip, and skipping it lets the same authenticator enrol twice — the user then sees two identical entries in their picker and cannot tell them apart. attestationType: 'none' is the right default for a consumer app: requesting attestation gives you an authenticator provenance statement you almost certainly have no policy for.
I use userVerification: 'preferred' rather than 'required'. The 'required' value rejects authenticators that cannot perform a local biometric or PIN check, which quietly excludes some security keys and older Android configurations. 'preferred' still reports whether verification happened via the userVerified flag, so you can gate sensitive actions on it instead of gating registration.
The browser half is four lines. Note the v13 signature — startRegistration takes an options object, not a positional argument:
I use route handlers rather than Server Actions here. navigator.credentials only runs in the browser inside a user gesture and in a secure context, so the flow is client-driven either way.
Because the browser matches the RP ID against the origin's registrable domain, and the match is one-directional: the RP ID may be the origin's domain or any parent domain of it, never a child. An origin of https://app.example.com may claim an rp.id of app.example.com or example.com. An origin of https://example.com may not claim app.example.com.
This is the mistake that is expensive to fix, because RP ID is written into the credential at registration and cannot be migrated. If you register users on app.example.com and later add a second product on dash.example.com, every existing passkey is stranded on the first subdomain.
For genuinely different sites — a per-country domain, or a separate brand — the mechanism is Related Origin Requests. The RP serves a JSON document at https:///.well-known/webauthn with Content-Type: application/json:
Related Origin Requests shipped in Chrome 128 and Safari 18. Browsers cap how many distinct registrable-domain labels they will process from that list — Chrome stops at five — so it is a mechanism for a handful of sibling domains, not a wildcard.
One local-development note: localhost is a secure context and works over plain HTTP, but any other dev hostname must be served over HTTPS, and the RP ID has to equal that hostname. A passkey registered against a local HTTPS dev hostname will never authenticate against production.
Conditional mediation is what puts a passkey inside the browser's autofill dropdown instead of a modal dialog. It needs three things together: a discoverable credential, an input marked autocomplete="username webauthn", and a get() call with mediation: 'conditional'.
Two traps live in that snippet. First, a conditional get() returns a promise that stays pending indefinitely — it resolves only when the user picks a passkey from the dropdown. Start it once when the sign-in form mounts and abort it on unmount; firing a second get() while one is outstanding throws instead of replacing it.
Second, conditional mediation only offers discoverable credentials and requires an empty allowCredentials. If your authentication options endpoint helpfully fills allowCredentials from a known username, the autofill chip silently never appears and you will blame the browser.
