DEV Community

Cover image for Passkeys Are Easy to Demo and Hard to Ship. Here's the Part the Tutorials Skip
Deepak Gupta
Deepak Gupta

Posted on

Passkeys Are Easy to Demo and Hard to Ship. Here's the Part the Tutorials Skip

Every passkey tutorial ends at the same triumphant moment. You tap your fingerprint, the demo logs you in, and the author declares the password dead.

Then you ship to real users and find out the demo was the easy 20%. The other 80% is recovery, multi-device gaps, and the dozen ways a real person's setup differs from a clean laptop with a fresh Touch ID enrollment.

Below is working WebAuthn code for registration and login, then the part production teams get stuck on. The second half is where most passkey rollouts quietly stall.

The 20% everyone shows you

Passkeys are WebAuthn under a friendlier name. Two ceremonies: registration (create a credential) and authentication (prove you hold it). The private key never leaves the user's device or their synced keychain. Your server only ever sees a public key and signed challenges. That is the whole security win, and it is why passkeys are phishing-resistant in a way TOTP and SMS never were.

I use @simplewebauthn here because rolling your own WebAuthn parsing is the kind of custom-crypto liability you do not want on your critical path. Boring, audited, standard.

The code targets @simplewebauthn/server and @simplewebauthn/browser v13.x. Field names shifted between major versions, so check your installed version before copying.

Registration, server side

import {
  generateRegistrationOptions,
  verifyRegistrationResponse,
} from '@simplewebauthn/server';

const rpID = 'example.com';          // your registrable domain, no scheme, no port
const origin = 'https://example.com';
const rpName = 'Example App';

// Step 1: hand the browser a challenge + options
async function startRegistration(user) {
  const options = await generateRegistrationOptions({
    rpName,
    rpID,
    // stable per-user handle so every passkey this user creates
    // groups under one identity in their password manager
    userID: new TextEncoder().encode(user.id),
    userName: user.email,
    // stop users re-registering a credential they already have
    excludeCredentials: user.credentials.map((c) => ({
      id: c.id,
      transports: c.transports,
    })),
    authenticatorSelection: {
      residentKey: 'required',      // discoverable credential = usernameless login later
      userVerification: 'preferred',
    },
  });

  await saveChallenge(user.id, options.challenge); // you MUST persist this
  return options;
}

// Step 2: verify what the browser sends back
async function finishRegistration(user, body) {
  const expectedChallenge = await getChallenge(user.id);

  const verification = await verifyRegistrationResponse({
    response: body,
    expectedChallenge,
    expectedOrigin: origin,
    expectedRPID: rpID,
    // must match userVerification: 'preferred' above, or you reject
    // legitimate logins on devices that skip verification
    requireUserVerification: false,
  });

  if (!verification.verified) throw new Error('registration failed');

  const { credential } = verification.registrationInfo;
  await saveCredential(user.id, {
    id: credential.id,
    publicKey: credential.publicKey,   // store as bytes
    counter: credential.counter,
    transports: body.response.transports ?? [],
  });
}
Enter fullscreen mode Exit fullscreen mode

Registration, browser side

import { startRegistration } from '@simplewebauthn/browser';

async function register() {
  const options = await fetch('/webauthn/register/start', { method: 'POST' })
    .then((r) => r.json());

  // this is the line that pops the OS/browser passkey UI
  const attResp = await startRegistration({ optionsJSON: options });

  await fetch('/webauthn/register/finish', {
    method: 'POST',
    headers: { 'content-type': 'application/json' },
    body: JSON.stringify(attResp),
  });
}
Enter fullscreen mode Exit fullscreen mode

Authentication

Same shape, mirror image. Because we required a discoverable (resident) credential above, the user can log in without typing anything. The OS shows them which passkeys exist for your site.

import {
  generateAuthenticationOptions,
  verifyAuthenticationResponse,
} from '@simplewebauthn/server';

async function startLogin() {
  const options = await generateAuthenticationOptions({
    rpID,
    userVerification: 'preferred',
    // allowCredentials omitted => usernameless: the platform offers all its passkeys
  });
  await saveChallenge('anon-session-id', options.challenge);
  return options;
}

async function finishLogin(body) {
  // the response tells you WHICH credential was used; look it up
  const cred = await findCredentialById(body.id);
  const expectedChallenge = await getChallenge('anon-session-id');

  const verification = await verifyAuthenticationResponse({
    response: body,
    expectedChallenge,
    expectedOrigin: origin,
    expectedRPID: rpID,
    requireUserVerification: false,   // match the 'preferred' setting above
    credential: {
      id: cred.id,
      publicKey: cred.publicKey,
      counter: cred.counter,
      transports: cred.transports,
    },
  });

  if (!verification.verified) throw new Error('auth failed');
  await updateCounter(cred.id, verification.authenticationInfo.newCounter);
  return cred.userId; // now start your session
}
Enter fullscreen mode Exit fullscreen mode

That is a working, phishing-resistant login in about 80 lines. Ship it and you will feel great for about a day, until the support tickets start. Here is what they will be about.

The 80% nobody shows you

1. Recovery is the whole ballgame

A password has a built-in recovery story everyone understands: "forgot password" emails a reset link. Passkeys do not. If a user's passkey is on a device they lost and it was not synced, that credential is gone. There is no reset link for a private key that no longer exists.

So before you launch passkeys, you have to answer one question: what happens when a user shows up with zero working passkeys? Your options, roughly:

  • Fall back to email magic link or OTP to re-authenticate, then let them enroll a new passkey. Simple, but your account security is now only as strong as your email channel. Be honest about that in your threat model.
  • Require a second passkey at enrollment (phone plus laptop). Great security, worse conversion, and users hate being told to enroll twice.
  • Recovery codes, the same ten-single-use-codes pattern MFA uses. Reliable, but users lose them.

There is no clean answer, only tradeoffs. The mistake is shipping passkeys without deciding, then improvising a recovery flow under pressure that quietly becomes your weakest link. Design recovery first, not last.

2. "Synced" is a promise you do not control

Apple syncs passkeys through iCloud Keychain. Google syncs through Password Manager. These are separate silos. A user who enrolled on their iPhone and then logs in on a Windows machine in Chrome may find their passkey simply is not there, because it lives in an Apple silo the Windows box cannot see.

Cross-device sign-in exists (the hybrid or QR-code flow, where you scan a code with your phone to authenticate on a laptop), and it works, but it is slower and less obvious than the happy-path demo suggests. Assume your users will have passkeys stranded in ecosystems, and make enrolling an additional passkey on a new device a first-class, one-tap flow rather than something buried in settings.

3. Do not delete the fallbacks yet

The dream is "kill the password." The reality for the next few years is passkeys as the preferred method with graceful fallbacks, not the only method. Enterprise-managed devices, kiosks, older browsers, and users who just are not ready all need a path in. Treat passkeys as an upgrade you nudge people toward, not a wall you put in front of them. Conversion dies at walls.

4. The signature counter will betray you

Notice counter in the code above. WebAuthn includes a signature counter meant to detect cloned authenticators. Sounds great. In practice, synced passkeys often report a counter of 0 and never increment, because a credential synced across five devices cannot keep a coherent count. If you hard-fail authentication on a non-incrementing counter, you lock out a large chunk of your users. Store it, use it as a soft signal for hardware keys, and do not treat a flat counter as an attack for synced credentials.

5. Naming credentials for humans

Users end up with several passkeys and no idea which is which. "Created Jan 3" is useless. Capture the transports and a friendly label at enrollment ("iPhone", "Work laptop") so that when someone wants to revoke a lost device, your account-security page is actually usable. This is a small feature that prevents a whole category of "I cannot tell which one to delete" tickets.

6. Enterprise wants attestation; consumers do not

If you sell to enterprises, some will require attestation: cryptographic proof of what kind of authenticator was used, so they can mandate hardware keys and block phones. WebAuthn supports it (attestation: 'direct'), but it adds real complexity, privacy friction, and a metadata-verification burden. For a consumer app, requesting attestation mostly just adds a scary extra prompt for no benefit. Know which product you are building before you turn it on.

The pattern that actually ships

After enough of these rollouts, the shape that works looks like this:

  • Passkeys as the preferred, promoted method, with email OTP or an authenticator app kept as fallbacks during the transition.
  • Recovery designed on day one, not bolted on after the first lockout.
  • Enrolling an additional passkey per device made trivial and actively encouraged.
  • The counter treated as a soft signal, never a hard block for synced credentials.
  • Human-readable credential management so revocation is self-service.

Get those five right and passkeys stop being a demo and start being infrastructure your users barely notice, which is the entire point.

Where the interesting arguments happen

Almost none of this is settled. Recovery strategy, how aggressively to deprecate passwords, whether to require attestation, how to handle the synced-versus-hardware distinction: these are live debates among people shipping identity for a living, and the right answer depends on your threat model and your users.

That is the stuff I go back and forth on with other practitioners in the Start with Identity community, a group of IAM and CIAM engineers comparing notes on exactly these production edge cases. Independent, no vendor pitch, just people who have hit the same walls. If you are shipping passkeys and want to pressure-test your recovery flow against people who have already broken theirs, come argue about it with us.

The code above gets you a working passkey login this afternoon. The conversation is how you keep it working when real users show up.

Top comments (0)