Rolling Out Passkeys Without Locking Users Out
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 serve
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 Β· OAuth 2.1, OIDC ID Tokens & PKCE Forensics
How do you add passkeys to a web app without locking users out?
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.
What a passkey actually changes
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.
Step 1: registration on the server
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.
// 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.
Step 2: sign-in with browser autofill
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:
<input type="text" name="username" autocomplete="username webauthn" />
import { startAuthentication, browserSupportsWebAuthnAutofill } from '@simplewebauthn/browser';
if (await browserSupportsWebAuthnAutofill()) {
const optionsJSON = await fetch('/passkeys/login/options', { method: 'POST' }).then(r => r.json());
const authResp = await startAuthentication({ optionsJSON, useBrowserAutofill: true });
await fetch('/passkeys/login/verify', {
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(authResp),
});
}
On the server, look up the stored credential by response.id, call verifyAuthenticationResponse with the stored public key and counter, then update the counter and a lastUsedAt column. Synced passkeys usually report a counter of zero, so treat a non-increasing counter as a signal to log, not an automatic lockout.
Step 3: debug failures from the response itself
When verification fails, the browser's response already tells you why. The clientDataJSON field is base64url-encoded JSON containing type, challenge and origin. Paste it into a Base64 decoder (switch to base64url) and pretty-print the result with a JSON formatter:
{
"type": "webauthn.get",
"challenge": "q3kV1x...",
"origin": "https://app.example.com",
"crossOrigin": false
}
In my outage, origin was https://app.example.com while the server expected only https://example.com. Comparing challenge with what you stored in the session catches the other classic bug: a second tab or a retry overwrote the challenge. Only decode responses from your own test accounts or your own logs, and never log full responses in production.
Step 4: fallbacks and rollout metrics
Roll out in phases. First, offer "Add a passkey" in account settings to signed-in users. Second, prompt after a successful password login. Third, once success rates are high, make passkey the first option at sign-in while keeping the password and recovery paths. Track per phase: registration completion rate, passkey sign-in success rate, fallback rate to password and support tickets tagged "passkey". If fallback rises after a browser release, you know where to look.
Let users see, rename and delete their passkeys, and show lastUsedAt. Ask them to register a second one. A user with two passkeys almost never needs recovery.
Diagnostic checklist
- [ ] RP ID chosen deliberately and documented; it matches every login origin.
- [ ]
expectedOriginlists every real origin, including subdomains and native app origins. - [ ] Challenges are random, stored server side, single use and short lived.
- [ ]
userIDis a random handle, not an email or database ID. - [ ] Credentials are stored with public key, counter, transports, device type and backup state.
- [ ] Registration requires an authenticated, recent session.
- [ ] Users can list, rename and remove passkeys and are encouraged to add a second.
- [ ] Password or recovery fallback remains and is tested.
- [ ] Registration, sign-in success and fallback rates are measured per browser.
- [ ] Passkey added or removed events are audited and the user is notified.
FAQ
Do passkeys replace passwords immediately?
Not on day one. Offer them alongside passwords, measure, and only remove passwords for users who have at least two passkeys and a tested recovery method.
What happens if a user loses their phone?
Synced passkeys restore through their platform account. Device-bound ones do not, which is why a second passkey and a solid recovery flow matter. Part 4 covers recovery hardening.
Should I require attestation?
Only if you must restrict which authenticators are allowed, for example hardware keys for admins. For most apps, 'none' reduces friction and privacy concerns.
Can I share passkeys across subdomains?
Yes, if the RP ID is the parent domain and every subdomain origin is listed in your expected origins.
Do passkeys work with my existing sessions?
Yes. A passkey sign-in should create the same hardened session as any other login. Part 2 covers rotation, timeouts and revocation.
Is a library necessary?
Strongly recommended. Parsing CBOR, COSE keys and attestation by hand is where subtle verification bugs come from.
Next in this series, Part 2 covers session lifecycle hardening after sign-in.
Follow & Connect
- Website: PixelValut Tools
- YouTube: PixelValut (@pixel-valut)
- TikTok: PixelValut (@pixelvalut007)
- GitHub: Muhammad Ali Mughal (@ali111887)
- LinkedIn: Muhammad Ali Mughal
Originally published by Dev.to Security. Aggregated on AIWithGhost for educational purposes β full credit and traffic to the original publisher.