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. 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 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. How do I 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. I use @simplewebauthn/server v13 rather than hand-rolling CBOR parsing. // app/api/passkey/register/options/route.ts import { generateRegistrationOptions } from '@simplewebauthn/server'; export async function POST() { const user = await requireSession(); const options = await generateRegistrationOptions({ rpName: 'Example', rpID: process.env.RP_ID!, // 'example.com' — the apex, never the subdomain userID: user.handle, // Uint8Array, ({ id: c.id, transports: c.transports, })), authenticatorSelection: { residentKey: 'required', userVerification: 'preferred', }, }); await saveChallenge(user.id, options.challenge); // single use, short TTL return Response.json(options); } Enter fullscreen mode Exit fullscreen mode 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: import { startRegistration } from '@simplewebauthn/browser'; const optionsJSON = await fetch('/api/passkey/register/options', { method: 'POST' }) .then((r) => r.json()); const attestation = await startRegistration({ optionsJSON }); await fetch('/api/passkey/register/verify', { method: 'POST', body: JSON.stringify(attestation), }); Enter fullscreen mode Exit fullscreen mode 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. Why does my 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 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: { "origins": [ "https://example.co.uk", "https://example.de", "https://example.app" ] } Enter fullscreen mode Exit fullscreen mode 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. How do I make the passkey autofill prompt appear? 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'. import { startAuthentication } from '@simplewebauthn/browser'; useEffect(() => { const controller = new AbortController(); (async () => { if (!(await PublicKeyCredential.isConditionalMediationAvailable?.())) return; const optionsJSON = await fetch('/api/passkey/auth/options').then((r) => r.json()); try { const assertion = await startAuthentication({ optionsJSON, useBrowserAutofill: true }); await submitAssertion(assertion); } catch (err) { if ((err as Error).name !== 'AbortError') throw err; } })(); return () => controller.abort(); }, []); Enter fullscreen mode Exit fullscreen mode 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. 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, since the entire assertion arrives as attacker-controllable JSON. import { verifyAuthenticationResponse } from '@simplewebauthn/server'; const verification = await verifyAuthenticationResponse({ response: assertion, expectedChallenge: storedChallenge, expectedOrigin: 'https://example.com', expectedRPID: 'example.com', credential: { id: stored.id, publicKey: stored.publicKey, counter: stored.counter, transports: stored.transports, }, requireUserVerification: false, }); await deleteChallenge(storedChallenge); // delete on failure too if (verification.verified) { await updateCounter(stored.id, verification.authenticationInfo.newCounter); } Enter fullscreen mode Exit fullscreen mode Delete the challenge whether verification succeeded or failed. A challenge that survives a failed attempt is a replay window, and it is the single most common flaw I 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 0, so a naive rule of "reject unless the new counter is greater than the stored one" 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 tell you whether a credential is synced across a user's devices or bound to one piece of hardware. Passkeys vs password and TOTP vs magic links — which do I ship? Factor Passkey (WebAuthn) Password + TOTP Magic link Phishing resistance Enforced by the browser via RP ID matching None — codes can be relayed in real time None — links are forwardable Server stores a secret No, only a public key Yes, hash plus TOTP seed No, but a live token sits in the mailbox Sign-in steps One gesture Two fields plus an app switch App switch to email, then back Works on a borrowed device Yes, via cross-device hybrid transport Yes Yes Main failure mode Recovery when every synced device is lost Real-time phishing and seed loss Email deliverability and mailbox compromise My default is passkey-first with a password kept as recovery, and TOTP retained only for accounts that already had it. Magic links stay in the stack for onboarding, not 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 you deleted server-side, the user selects it, and they get an error they have no way to interpret. The WebAuthn Signal API exists to close that gap. await PublicKeyCredential.signalAllAcceptedCredentials?.({ rpId: 'example.com', userId: base64urlUserHandle, allAcceptedCredentialIds: remaining.map((c) => c.id), }); Enter fullscreen mode Exit fullscreen mode There are three methods and they map to three events. signalAllAcceptedCredentials() runs after a user deletes a credential or right after a successful sign-in, and prunes stale entries. signalUnknownCredential() runs when an assertion arrives for a credential ID you have no record of, and removes that single orphan. signalCurrentUserDetails() runs after a user changes their name or email. The Signal API landed in Chromium 132, so optional-chain every call and treat it as progressive enhancement. One server-side rule is worth enforcing regardless of browser support: never let a user delete their last credential unless another way in exists. FAQ Q: Can I 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 rp.id: 'app.example.com' will never work on example.com. Q: Why doesn't the passkey appear in my browser's autofill dropdown? A: Three causes, in order of likelihood: the input is missing autocomplete="username webauthn", the get() call is missing mediation: 'conditional', or the authentication options include a non-empty allowCredentials. Q: What should I put in user.id? A: A random opaque handle of at most 64 bytes — a UUID or 16 random bytes stored alongside the account row. The WebAuthn spec states user.id must not contain personally identifying information. Q: Do I 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. Q: Should I reject a sign-in 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 0, so a strict monotonic check locks out the majority of real passkey users. Originally published on devya.dev. Also on eng-ahmed.com. Built by Devya Solutions.
Passkeys in Production: Field Notes on WebAuthn, Conditional UI, and the RP ID That Broke My Login
Full Article
Original Source
Read the full article at Dev →KhanList aggregates and links to publicly available news content. We do not host full articles from third-party sources. Always verify important information with original sources.