Search nomadLab

Passkeys in Production: Two Fields Say Who Needs a Second One

Your registration response already tells you whether the passkey the user just made will survive their next laptop. credentialDeviceType and credentialBackedUp turn the blanket advice to register two devices into a prompt for the people who actually need it.

Updated

The WebAuthn ceremony is the easy part. You can wire registration and login in an afternoon. What eats two sprints is everything around it: getting the RP ID right, designing recovery that does not quietly reopen the phishing hole you just closed, and explaining to a user why the passkey from their old laptop is not on the new one.

That last problem has a server-side answer that most implementations throw away. verifyRegistrationResponse returns two fields alongside the credential, and the package’s own type documentation shouts about both of them in bold: credentialDeviceType, which is 'singleDevice' or 'multiDevice', and credentialBackedUp, a boolean. Together they say whether the thing you just saved is going to exist after the user replaces the hardware.

Three combinations of credentialDeviceType and credentialBackedUp from a WebAuthn registration response. singleDevice with backedUp false means the credential is bound to that device, so ask for a second one now. multiDevice with backedUp false means it is syncable but not yet synced, so nudge without blocking. multiDevice with backedUp true means it survives a new laptop, so leave the user alone. What the registration response already told you Two fields on registrationInfo, and what each combination means for recovery singleDevice backedUp: false bound to this device ask for a second now multiDevice backedUp: false syncable, not synced nudge, do not block multiDevice backedUp: true survives a new laptop leave them alone
Persist both fields at registration. They are the difference between nagging every user to add a second passkey and nagging the third of them who will otherwise be locked out.

Everyone’s rollout advice says register two devices at onboarding. That is right, and it is also the sort of blanket prompt users learn to dismiss. Store these two fields and you can ask only the people whose first credential is not coming back, which is a prompt they will actually read.

The Windows answer changed, and it was never about Windows

The received wisdom is that Windows Hello passkeys are bound to the machine’s TPM and do not sync, so a new PC means a recovery flow. That was true and it is now conditional.

Windows 11 supports plugin credential providers, and Microsoft Password Manager saves and syncs passkeys across devices signed into the same Microsoft account, with the sync path documented in Microsoft’s own engineering write-up in April 2026. Third-party managers plug in the same way. So a passkey created on Windows might be TPM-bound and might sync, and which one it is depends on which provider handled the save.

Your server cannot tell from the platform. It can tell from the two fields above, which is exactly why they exist. Stop branching on user agent and branch on the credential.

RP ID and origin, the part that breaks after deploy

The Relying Party ID is your domain with no scheme and no port. The browser requires it to be either the current origin’s domain or a registrable suffix of it.

So if users sign in at app.example.com, you may set the RP ID to app.example.com or to example.com. Set it to example.com and the credential also works at account.example.com and www.example.com. Set it to the subdomain and the credential is locked there, and moving the app to a different subdomain later invalidates every passkey you have issued.

Register against the registrable parent domain unless you have a hard isolation reason not to. This is not a decision you want to revisit with a million credentials in the wild.

The origin you verify against is the opposite: it includes scheme and port, so https://app.example.com in production and http://localhost:5173 in development. WebAuthn allows localhost over plain HTTP as a special case, which is the only reason local development works. Everywhere else it is HTTPS or nothing, and you want HSTS so nobody can strip it.

const rpName = 'Example App';
const rpID = process.env.RP_ID;        // 'example.com' in prod, 'localhost' in dev
const origin = process.env.RP_ORIGIN;  // 'https://app.example.com' / 'http://localhost:5173'

Drive both from the environment. The number of outages caused by a hardcoded localhost reaching production is not zero.

The v13 API, including the part its own docs get wrong

@simplewebauthn/server is on 13.3.2 as of 24 June 2026, with the browser package on 13.3.0. The v13 line renamed things and some of the code circulating online is still v12 shaped.

Two options in the registration snippets people copy are already the defaults. attestationType defaults to 'none', which is what you want unless you must prove a credential lives on a specific certified authenticator. And authenticatorSelection defaults to { residentKey: 'preferred', userVerification: 'preferred' }, so writing that block out changes nothing. userID is optional too and defaults to a random identifier; pass your own only if you want to control it.

What is worth writing explicitly is excludeCredentials, which stops a user registering the same authenticator twice and hitting a baffling dead end.

const options = await generateRegistrationOptions({
  rpName,
  rpID,
  userName: user.email,
  excludeCredentials: (await getUserCredentials(user.id)).map((c) => ({
    id: c.id,
    transports: c.transports,
  })),
});
req.session.currentChallenge = options.challenge;

On verification, here is the trap. The type of registrationInfo is:

registrationInfo: {
  fmt: AttestationFormat;
  aaguid: string;
  credential: WebAuthnCredential;   // { id, publicKey, counter, transports? }
  credentialDeviceType: 'singleDevice' | 'multiDevice';
  credentialBackedUp: boolean;
  origin: string;
  rpID?: string;
  // ...
}

The JSDoc block sitting directly above that type in the shipped package still describes registrationInfo.credentialPublicKey and registrationInfo.credentialID, which are the v12 names and no longer exist. If you generate code from the doc comment rather than the type, it compiles in JavaScript and gives you undefined at runtime. Read the .d.ts, not the prose above it.

const { credential, credentialDeviceType, credentialBackedUp } = verification.registrationInfo;
await saveCredential(user.id, {
  id: credential.id,
  publicKey: credential.publicKey,   // Uint8Array, store as bytea or base64
  counter: credential.counter,
  transports: req.body.response.transports ?? [],
  deviceType: credentialDeviceType,
  backedUp: credentialBackedUp,
});
req.session.currentChallenge = undefined;   // burn it

One more mismatch to know about. verifyRegistrationResponse and verifyAuthenticationResponse both default requireUserVerification to true, while the registration options default userVerification to 'preferred'. So you can ask the authenticator for something optional and then reject the result for not having it. Pick one posture and set it on both sides rather than inheriting two different defaults.

Authentication mirrors registration, with one signature difference: expectedRPID is optional on the registration verifier and required on the authentication one, and the stored credential goes in as credential, not authenticator as it was in v12.

Recovery is where the phishing resistance actually gets decided

Your authentication is exactly as phishing-resistant as your weakest recovery path. Spend a quarter making login unphishable and then offer to text a code when someone loses a device, and you have installed the SIM-swap door you thought you had removed.

A second credential the user already holds is the best recovery mechanism there is, which is what the two fields above are for. After that, one-time recovery codes generated at registration, shown once, stored only as hashes. They are low-tech and offline and there is nothing to phish in real time.

Treat email as a recovery channel rather than an authentication channel, rate-limit it hard, and make any path that does not involve an existing credential trigger step-up scrutiny: device signals, a geolocation delta, a cooldown window.

The principle is that losing one of two devices should be trivial and losing everything should be deliberately slow. A recovery flow as fast as normal login is a recovery flow an attacker will prefer to the front door.

Rolling out without setting the helpdesk on fire

Do not turn passwords off on day one. Ship registration as opt-in in account settings first, changing nothing for anyone who ignores it. Then make the passkey the recommended path, surfacing it first for users who have one while keeping the fallback visible but secondary. Only discuss deprecation after a real majority of successful logins are passkey-authenticated.

That last phrase is the metric, and it is not the one most teams track. Registered passkeys is a vanity number. Plenty of users register one and then fall straight back to the saved password because your login screen made that easier. If the gap between registrations and passkey logins is wide, the problem is your login UI, and no amount of password deprecation will fix it.

Two gotchas that cost a day each

Ship an explicit “Sign in with a passkey” button and treat conditional UI as progressive enhancement. Autofill-based passkey selection is nicer when it works, but it depends on discoverable credentials and browser conditions and it fails silently when they are not met. If it is your only entry point, some fraction of users see nothing at all.

And handle the second abort. If conditional UI is already in flight when the user clicks the explicit button, you have two overlapping ceremonies and the browser throws. Hold an AbortController, abort the in-flight request before starting the new one, and swallow the resulting AbortError rather than rendering it as a failure.

Pick one low-stakes internal app, wire up registration and login, register a passkey from a phone and one from a work laptop, and look at what credentialDeviceType came back for each. Then delete one and walk your own recovery flow. The hour teaches you more about what users will hit than any amount of reading, this post included.

Package versions and type signatures came from the npm registry and the published @simplewebauthn/server 13.3.2 type definitions on 23 August 2026; the Windows passkey sync behavior from Microsoft’s own documentation and engineering blog.

Keep reading