Skip to main content
Passkeys in Production: What We Learned Shipping WebAuthn, Conditional UI, and RP ID Rules

Passkeys in Production: What We Learned Shipping WebAuthn, Conditional UI, and RP ID Rules

August 9, 2026
Full-Stack Engineering
11 min read

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 our team 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 navigator.credentials.get() is called with mediation set to conditional.

Key takeaways

RP ID is permanent and one-directional. A passkey registered with rp.id of example.com is usable from app.example.com, but a passkey registered with rp.id of app.example.com is never usable from example.com. Choose the apex domain before the first user registers.

user.id is an opaque account handle, not an identifier. The WebAuthn specification caps it at 64 bytes and states it must not contain personally identifying information, so an email address there is baked into the credential and cannot be rotated.

Usernameless sign-in requires a discoverable credential, requested with authenticatorSelection.residentKey set to required. Without it, authentication must send an allowCredentials list, which means the username is needed first.

Conditional UI is a separate call. The passkey entry inside the browser autofill dropdown needs autocomplete="username webauthn" on the input plus mediation set to 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 user who loses every device syncing their passkey still needs a documented way back in.

What is a passkey, and what does it actually replace?

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 the 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 that signature against the stored public key.

Because no shared secret crosses the network, a phishing site has nothing to capture. The stronger property is enforced by the browser rather than by the user: the browser only surfaces a credential whose RP ID matches the current origin's registrable domain, so a lookalike domain cannot make the authenticator sign at all. That is the failure mode TOTP never closed, because a one-time code can be relayed to an attacker in real time and a passkey signature cannot.

A passkey replaces the password plus the second factor, collapsed into one prompt. It does not replace session management, authorization, rate limiting, or account recovery. We treat passkeys as a credential upgrade, not as a complete authentication system.

How do we register a passkey from a Next.js App Router route handler?

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. We use @simplewebauthn/server v13 rather than hand-rolling CBOR parsing.

The options call sets rpID to the apex domain from an environment variable, passes userID as an opaque Uint8Array of at most 64 bytes, sets attestationType to none, lists existing credentials in excludeCredentials, and requests authenticatorSelection.residentKey of required. The generated challenge is then saved server-side with a short time-to-live and single-use semantics.

excludeCredentials is the field teams skip most often, and skipping it lets the same authenticator enrol twice — the user then sees two identical entries in their picker with no way to tell them apart. attestationType of none is the right default for a consumer product, because requesting attestation returns an authenticator provenance statement most teams have no policy for and it adds a privacy prompt on some platforms.

We use userVerification of 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. The preferred value still reports whether verification happened through the userVerified flag, so sensitive actions can be gated on it instead of gating registration.

On the browser side, startRegistration from @simplewebauthn/browser v13 takes an options object rather than a positional argument, which is the breaking change from v10. We use route handlers rather than Server Actions here, because navigator.credentials only runs in the browser inside a user gesture and in a secure context, and two plain request/response pairs are far easier to log and replay in tests.

Why does a passkey work on one subdomain and not another?

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 own domain or any parent domain of it, never a child. An origin of app.example.com may claim an rp.id of app.example.com or example.com, while an origin of example.com may not claim app.example.com.

This is the expensive mistake, because RP ID is written into the credential at registration and cannot be migrated. If users register on app.example.com and a second product later launches on dash.example.com, every existing passkey is stranded on the first subdomain. Register at the apex from the start unless there is a deliberate isolation requirement.

For genuinely different sites — a per-country domain or a separate brand — the mechanism is Related Origin Requests. The relying party serves a JSON document at /.well-known/webauthn on the RP ID host, with Content-Type of application/json and an origins array listing the sibling domains. Related Origin Requests shipped in Chrome 128 and Safari 18, and browsers cap how many distinct registrable-domain labels they will process — Chrome stops at five — so it covers 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 development hostname must be served over HTTPS and the RP ID has to equal that hostname. A passkey registered against a local HTTPS development hostname will never authenticate against production, so development credentials belong in a separate table or namespace.

How do we make the passkey autofill prompt appear?

Conditional mediation is what places a passkey inside the browser's autofill dropdown instead of a modal dialog. It requires three things together: a discoverable credential, an input marked autocomplete="username webauthn", and a navigator.credentials.get() call with mediation set to conditional.

Two traps live in that flow. First, a conditional get() returns a promise that stays pending indefinitely, resolving only when the user picks a passkey from the dropdown. Start it once when the sign-in form mounts and abort it on unmount with an AbortController; 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 the authentication options endpoint helpfully fills allowCredentials from a known username, the autofill entry silently never appears. Always feature-detect with PublicKeyCredential.isConditionalMediationAvailable() and fall back to a normal button-triggered get().

What must the server verify on every assertion?

The server must verify the challenge, the origin, the RP ID hash, the user-presence flag, and the signature. A client that reports success proves nothing, because the entire assertion arrives as attacker-controllable JSON.

With @simplewebauthn/server v13, verifyAuthenticationResponse takes the response, the expected challenge, the expected origin, the expected RP ID, and a credential object holding the stored id, publicKey, counter, and transports. The stored challenge must be deleted whether verification succeeded or failed — a challenge that survives a failed attempt is a replay window, and it is the most common flaw we find when reviewing a hand-written WebAuthn implementation.

The signature counter deserves a warning. It exists for clone detection, but most synced platform authenticators always report zero, so a naive rule of rejecting any assertion whose counter did not increase locks out every Apple and Google passkey user. Enforce monotonic increase only when the stored counter and the new counter are both non-zero.

Persist credentialBackedUp and credentialDeviceType from the registration result. Those flags say whether a credential is synced across a user's devices or bound to a single piece of hardware, which is the signal needed before prompting someone to enrol a second passkey.

Phishing resistance: a passkey is protected by browser-enforced RP ID matching; password plus TOTP has none, because codes can be relayed in real time; magic links have none, because links are forwardable.

Server-side secrets: a passkey deployment stores only a public key; password plus TOTP stores a hash and a TOTP seed; magic links store no long-lived secret but leave a live token sitting in a mailbox.

Sign-in effort: a passkey is one gesture; password plus TOTP is two fields and an app switch; a magic link is an app switch to email and back.

Main failure mode: passkeys fail at recovery when every synced device is lost; password plus TOTP fails to real-time phishing and seed loss; magic links fail on email deliverability and mailbox compromise.

Our default is passkey-first with a password retained as recovery, and TOTP kept only for accounts that already had it. Magic links stay in the stack for onboarding rather than for repeat sign-in, because they make routine login depend on email latency.

What breaks after a user deletes a passkey?

Nothing breaks on the server, and everything breaks in the operating system's picker: the platform passkey manager keeps offering a credential that was deleted server-side, the user selects it, and they receive an error they cannot interpret. The WebAuthn Signal API exists to close that gap.

There are three methods mapping to three events. signalAllAcceptedCredentials() runs after a user deletes a credential or immediately after a successful sign-in, and prunes stale entries by passing the rpId, the base64url user handle, and the list of credential IDs that remain valid. signalUnknownCredential() runs when an assertion arrives for a credential ID with no server-side record, and removes that single orphan. signalCurrentUserDetails() runs after a name or email change so the picker stops showing a stale label. The Signal API landed in Chromium 132, so every call should be optional-chained and treated as progressive enhancement.

One server-side rule holds regardless of browser support: never let a user delete their last credential unless another way in exists. We block the request when it would leave an account with zero passkeys and no password or recovery code, and return a specific error the interface can explain rather than a generic 400.

FAQ

Q: Can we set the RP ID to a subdomain and change it later?

A: No. RP ID is written into the credential at registration and cannot be migrated. A passkey registered with an rp.id of app.example.com will never work on example.com, so choose the apex domain before the first user enrols.

Q: Why doesn't the passkey appear in the browser's autofill dropdown?

A: Three causes, in order of likelihood: the input is missing autocomplete="username webauthn", the get() call is missing mediation set to conditional, or the authentication options include a non-empty allowCredentials. Conditional mediation only offers discoverable credentials.

Q: What should go in user.id?

A: A random opaque handle of at most 64 bytes, such as a UUID or 16 random bytes stored alongside the account row. The WebAuthn specification states user.id must not contain personally identifying information, so never use an email address or a sequential database ID.

Q: Do we still need passwords after shipping passkeys?

A: Keep one recovery path until passkey recovery is genuinely solved for your users. A passkey synced to a single vendor's cloud is lost when the user loses access to that account, and a device-bound passkey is lost with the device.

Q: Should a sign-in be rejected when the signature counter did not increase?

A: Only when both the stored counter and the new counter are non-zero. Most synced platform authenticators always report zero, so a strict monotonic check locks out the majority of real passkey users.