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
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.
// 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:
<input type="text" name="username" autocomplete="username webauthn" />
js
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.
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.
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.
expectedOrigin lists every real origin, including subdomains and native app origins.userID is a random handle, not an email or database ID.
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.
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.
Only if you must restrict which authenticators are allowed, for example hardware keys for admins. For most apps, 'none' reduces friction and privacy concerns.
Yes, if the RP ID is the parent domain and every subdomain origin is listed in your expected origins.
Yes. A passkey sign-in should create the same hardened session as any other login. Part 2 covers rotation, timeouts and revocation.
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.