DEV Community

Cover image for Rolling Out Passkeys Without Locking Users Out
Muhammad Ali Mughal
Muhammad Ali Mughal

Posted on

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 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.

A heavy steel vault door standing slightly open with warm light inside, AI-generated

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 });
});
Enter fullscreen mode Exit fullscreen mode

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" />
Enter fullscreen mode Exit fullscreen mode
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),
  });
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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.
  • [ ] expectedOrigin lists every real origin, including subdomains and native app origins.
  • [ ] Challenges are random, stored server side, single use and short lived.
  • [ ] userID is 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

Top comments (0)