Rolling Out Passkeys Without Locking Users Out A principal software engineer at PixelValut documented a passkey rollout that failed for roughly 30% of production users for two days because the login page was served from app.example.com while the backend verified against an RP ID and origin of example.com. The engineer recommends adding passkeys alongside passwords rather than replacing them, fixing RP ID and allowed origins before launch, storing each credential with its counter and transports, allowing multiple passkeys per user, and measuring sign-in success before making passkeys the default. The first time I shipped passkeys for a client's SaaS dashboard, sign-in worked perfectly on staging and failed for about 30% of users on production for two days. Nothing was wrong with the cryptography. Production served the login page from app.example.com , but the backend verified responses against an RP ID and origin configured as example.com and https://example.com . Chrome users on one path were fine; users who registered through the marketing site's embedded flow were not. Every failure looked the same in the logs: "verification failed". Passkeys are the best login upgrade most web apps can make right now, but the rollout details decide whether users love them or get locked out. I wrote this with help from AI tools and checked every snippet and config myself against SimpleWebAuthn v11+ and current Chrome, Safari and Firefox behaviour. I am a Principal Software Engineer in Karachi with 9+ years across PHP/Laravel, Vue, Node and .NET microservices, and I build the free developer tools at PixelValut. Everything here is about hardening authentication in an app you own. ๐Ÿ“š Series Navigation โ€” Passkeys & Session Hardening for Web Apps Part 1 This Post : Rolling Out Passkeys Without Locking Users Out Part 2: Session Lifecycle Hardening: Rotation, Timeouts & Server-Side Revocation Part 3: CSRF in 2026: SameSite, Fetch Metadata & Origin Checks Part 4: Account Recovery Hardening: The Side Door Next to Your Passkey Part 5: Protecting Login Endpoints: Credential-Stuffing Signals & Adaptive Rate Limits Part 6: Step-Up Authentication for Sensitive Actions Related earlier posts: Browser Secrets That Leak: Token Storage, Quotas & Outbound Controls https://dev.to/muhammadalimughal/browser-secrets-that-leak-token-storage-quotas-outbound-controls-cf2 ยท OAuth 2.1, OIDC ID Tokens & PKCE Forensics https://dev.to/muhammadalimughal/oauth-21-oidc-id-tokens-pkce-forensics-auditing-claims-nonces-audience-confusion-in-spas-2eh9 Direct answer: Add passkeys alongside existing login, not instead of it. Fix your RP ID and allowed origins first, store each credential with its counter and transports, let users register more than one, and offer passkeys through browser autofill. Keep a tested recovery path, then measure sign-in success before making passkeys the default. A passkey is a WebAuthn credential: a key pair where the private key stays in the user's authenticator phone, laptop, password manager or hardware key and your server stores only the public key. At login your server sends a random challenge, the authenticator signs it, and you verify the signature. The browser binds every response to the origin, which is why passkeys resist phishing: a lookalike domain cannot get a valid signature for your site. That also means configuration mistakes fail closed. The table below covers the options that matter most in a rollout: | Decision | Option A | Option B | What I default to | |---|---|---|---| | Passkey type | Synced iCloud Keychain, Google Password Manager, 1Password | Device-bound hardware key | Accept both; synced for most users | | Discoverable credential | residentKey: 'required' usernameless | 'preferred' | 'preferred' , then autofill handles the UX | | User verification | 'required' biometric/PIN every time | 'preferred' | 'preferred' for consumer, 'required' for admin roles | | Attestation | 'direct' verify authenticator model | 'none' | 'none' unless policy demands specific hardware | | RP ID | Exact host app.example.com | Registrable domain example.com | Registrable domain if subdomains share login | | Rollout | Replace passwords | Add as an option | Add as an option, measure, then promote | The RP ID choice is permanent for existing credentials. A passkey registered for app.example.com cannot be used on example.com . Pick the broadest domain you control and will keep, before the first user registers. I use SimpleWebAuthn on Node because it handles the CBOR and COSE parsing that is easy to get wrong. Registration has two endpoints: one to create options, one to verify the browser's response. js // auth/passkeys.ts Express, SimpleWebAuthn v11+ import { generateRegistrationOptions, verifyRegistrationResponse } from '@simplewebauthn/server'; const rpID = 'example.com'; const expectedOrigin = 'https://example.com', 'https://app.example.com' ; app.post '/passkeys/register/options', requireLogin, async req, res = { const user = req.user; const existing = await db.passkeys.findMany { where: { userId: user.id } } ; const options = await generateRegistrationOptions { rpName: 'Example App', rpID, userName: user.email, userID: new TextEncoder .encode user.webauthnId , // random, not the email or DB id attestationType: 'none', excludeCredentials: existing.map c = { id: c.credentialId, transports: c.transports } , authenticatorSelection: { residentKey: 'preferred', userVerification: 'preferred' }, } ; req.session.regChallenge = options.challenge; // single use, server side res.json options ; } ; app.post '/passkeys/register/verify', requireLogin, async req, res = { const { verified, registrationInfo } = await verifyRegistrationResponse { response: req.body, expectedChallenge: req.session.regChallenge, expectedOrigin, expectedRPID: rpID, } ; delete req.session.regChallenge; if verified || registrationInfo return res.status 400 .json { error: 'verification failed' } ; const { credential, credentialDeviceType, credentialBackedUp } = registrationInfo; await db.passkeys.create { data: { userId: req.user.id, credentialId: credential.id, publicKey: Buffer.from credential.publicKey , counter: credential.counter, transports: credential.transports ?? , deviceType: credentialDeviceType, // 'singleDevice' | 'multiDevice' backedUp: credentialBackedUp, label: req.body.label ?? 'Passkey', }} ; await audit req.user.id, 'passkey registered' ; res.json { ok: true } ; } ; Three details here prevent most support tickets. excludeCredentials stops users registering the same authenticator twice. The userID is a random handle, so you never leak an email or database ID to authenticators. And requireLogin means registration only happens inside an authenticated session, ideally a recent one. Conditional UI lets the browser offer passkeys in the username field's autofill menu, so users do not need to know what a passkey is: