The first passkey login I shipped to real users worked perfectly for forty minutes. Then the support tickets started.
A user with a personal MacBook and a work Windows laptop could not figure out why his iPhone passkey was not showing up on the Windows machine. A second user had set up a passkey on her phone, lost the phone in a taxi, and now could not get into her account because we had quietly deleted her password fallback when she enrolled. A third user was on a corporate-managed Chrome that had WebAuthn policy-locked to platform authenticators only, but our flow assumed roaming authenticators would always be offered.
None of these are bugs in WebAuthn. They are the gap between "passkeys work" as a protocol statement and "passkeys work for the actual humans using your product." Most articles on this topic stop at the first half. This one is about the second half, the part you only learn by shipping.
What Passkeys Actually Are, Stripped of Marketing
A passkey is a WebAuthn credential where the private key lives in something the user trusts (their device, their password manager, their security key) and the public key lives on your server. Authentication is a signature challenge. Your server sends a random nonce, the authenticator signs it with the private key, you verify the signature against the public key you stored at registration.
That much has been true since WebAuthn level 1 in 2019. What changed in 2022 and shipped broadly through 2024 and 2025 is the sync part. Apple, Google, and Microsoft started syncing WebAuthn credentials across devices through their cloud accounts. Then 1Password, Bitwarden, and Dashlane started doing the same across platforms. The credential is no longer locked to a single device.
The user-facing pitch is "no more passwords, no more phishing, your account is just there on every device you trust." The pitch is mostly true. The mostly part is where the work is.
Three things to internalize before writing any registration code:
- A passkey is bound to a relying party ID, which is your domain. Cross-domain passkeys do not exist. A passkey for
app.example.comcannot be used onexample.comunless you set the RP ID to the parent domain at registration time. You make this choice once and you live with it. - A user can have many passkeys. They will. Treat the credential as the primary key for authentication, not the user. One user, many credentials, with metadata on each one (device label, last used, transport types).
- The authenticator decides what is possible. Some authenticators are platform-bound (Touch ID without iCloud Keychain). Some are roaming (YubiKey). Some are syncing (iCloud Keychain, 1Password). Your code asks for what you want and the browser tells you what you got. You design around the answer, not around your assumptions.
The Protocol in One Page
Registration is a four-step dance. The browser API is navigator.credentials.create() with a publicKey options object. You generate the options on the server, send them down, the browser creates the credential, you send the attestation back, you verify and store.
// Server: generate registration options
const options = await generateRegistrationOptions({
rpName: 'Example',
rpID: 'example.com',
userID: new TextEncoder().encode(user.id),
userName: user.email,
attestationType: 'none',
excludeCredentials: existingCredentials.map((c) => ({
id: c.credentialId,
transports: c.transports,
})),
authenticatorSelection: {
residentKey: 'preferred',
userVerification: 'preferred',
authenticatorAttachment: undefined,
},
});
await sessionStore.set(session.id, { challenge: options.challenge });
return options;
Three knobs in that block matter more than they look:
-
attestationType: 'none'is the default for consumer apps. Anything else asks the authenticator to prove what it is, which is useful for regulated environments and a privacy concern for everyone else. Most consumer flows do not need it. -
residentKey: 'preferred'asks for a discoverable credential, which is what makes the "click sign in and just be signed in" flow work without typing a username. The browser respects the preference but does not always honor it. You handle both cases on login. -
authenticatorAttachment: undefinedmeans the user can pick a platform authenticator (Touch ID, Windows Hello) or a roaming one (security key, phone). Locking this toplatformwill exclude users who want their YubiKey. Locking tocross-platformwill exclude users who want Face ID. Leaving it open is almost always right.
Login (assertion) is the same shape inverted:
// Server: generate authentication options
const options = await generateAuthenticationOptions({
rpID: 'example.com',
userVerification: 'preferred',
allowCredentials: undefined, // empty for discoverable credential flow
});
await sessionStore.set(session.id, { challenge: options.challenge });
return options;
Leaving allowCredentials empty triggers the discoverable credential flow: the browser shows the user every passkey they have for your domain, they pick one, and you find out which user it is from the credential ID after the assertion. This is the flow you want. The alternative, asking the user for their username first and then sending the list of credentials they own, is fine for sign-in form layouts but gives up the magic.
The verification step on the server is where you check the signature, the challenge match, the origin, the RP ID hash, and the signature counter (if the authenticator increments one). @simplewebauthn/server handles all of that. You hand it the response, the expected challenge from the session, and your domain, and it tells you whether to trust this assertion.
Most of the protocol-level work is solved by the SimpleWebAuthn library on Node.js, webauthn-rs on Rust, and platform-specific equivalents on Go and Python. Writing it yourself in 2026 is not a sign of seriousness. It is a sign of not having read the spec carefully enough to notice how many ways there are to subtly miscount bytes when parsing the authenticator data.
The Account Model You Actually Need
The schema for storing passkeys is small but easy to get wrong. The shape that has held up for me across three production rollouts:
type User = {
id: string;
email: string;
emailVerifiedAt: Date | null;
createdAt: Date;
};
type Credential = {
id: string; // your primary key
userId: string; // foreign key
credentialId: Uint8Array; // WebAuthn credential ID
publicKey: Uint8Array; // COSE-encoded public key
signatureCounter: number;
transports: AuthenticatorTransport[];
deviceLabel: string; // user-editable
lastUsedAt: Date;
createdAt: Date;
backupEligible: boolean;
backupState: boolean;
};
Two fields people skip and regret: backupEligible and backupState. These come from flags on the authenticator data and they tell you whether the credential is syncing across the user's devices. A credential that is backupEligible: true, backupState: true is a credential that exists in iCloud Keychain or 1Password or similar. If the user loses their phone, that credential is still recoverable. A credential with backupEligible: false is locked to one device. If that device dies, the credential dies with it.
You do not show these flags to the user as raw booleans. You use them to decide what to tell the user about recovery. A user who has only single-device credentials needs more aggressive prompting to add a second factor or set up recovery. A user with synced credentials is in much better shape.
The transports array is what makes the autofill UI on the next device work. A credential created on an iPhone reports ['internal', 'hybrid']. The hybrid transport is what enables QR-code-mediated cross-device auth where the user scans a code on a desktop with their phone to log in. Storing transports correctly and passing them back in excludeCredentials and allowCredentials makes the browser surface the right options at the right moments.
The deviceLabel field exists because users will end up with five or six credentials and need to be able to tell them apart. "iPhone 15 Pro," "Work MacBook," "1Password," "YubiKey 5C." The browser does not give you a clean device name on registration. You ask the user. A small text input at the end of the registration flow with a sensible default like "Device added on May 8, 2026" is enough.
The Recovery Problem
Here is the part most demos skip. Passkeys without a recovery story are worse than passwords, because at least passwords have email-based reset flows that everyone understands.
The mental model that has worked: a user account needs at least two ways back in, and they need to be independent failure modes. If both of your recovery methods require the user's phone, losing the phone takes the user out of the account permanently. That is a churn event and, for some applications, a regulatory issue.
The recovery options worth combining:
- A second passkey, registered on a different authenticator. "Add another device" is the clean version of this. The phone is one credential, the password manager is another, the laptop's platform authenticator is a third.
- An emailed magic link. Cheap, familiar to users, and works as long as email is accessible. The downside is that it makes your account security exactly as good as the user's email security, which is a known weak link. For a consumer product this is usually acceptable. For a financial product it is not.
- A printed or shown-once recovery code. A 16-character string the user is told to save somewhere. Most users will not save it. The ones who will are exactly the users you want to keep.
- Identity verification through a third-party service. KYC providers can re-verify the user against their original ID. Expensive and slow. Use this for high-value accounts.
The pattern that holds up: at registration time, push the user to set up a second method before they finish onboarding. If they bail, mark the account as having weak recovery and show a banner on every login until they fix it. The friction is worth it. The cost of supporting "I lost my only passkey" tickets is high and the resolution is often "the user creates a new account and we lose their data."
The other thing to do at registration time: do not delete the password if the user has one. Add the passkey alongside, mark passkeys as preferred, and offer to remove the password later once the user has multiple working passkeys. A common rollout mistake is treating passkey registration as a one-way migration. It should be additive. The password becomes a fallback. Once the user has confirmed they can log in with their passkey on every device they use, you can offer to remove the password. Never remove it without an explicit user action.
The Cross-Device Reality
The hardest part of shipping passkeys is not writing the code. It is reasoning about what happens when a user sits down at a device that does not have their credential.
The clean cases:
- iPhone user opens Safari on their iPhone or Mac signed into the same iCloud account. The credential syncs. Login works.
- 1Password user with the browser extension installed and unlocked. The credential is in 1Password. The extension intercepts the WebAuthn ceremony. Login works.
- Android user with Google Password Manager and Chrome signed in. The credential syncs across their Android devices and Chrome on desktop. Login works.
The messy cases:
- Mac user logs in on a Windows laptop. iCloud Keychain does not exist on Windows. The user needs to use the cross-device flow: the browser shows a QR code, the user scans it with their iPhone, the iPhone authenticates over Bluetooth, and the desktop receives the assertion through a relay server. This works but it is not obvious to users. The first time they see the QR code they assume something is broken.
- A user with credentials only in their work device's platform authenticator goes home and tries to log in on their personal laptop. Same QR code flow needed. If their work device is in their pocket, it works. If they left it at the office, they are locked out unless they have a second method.
- A user on a corporate-managed device where IT has disabled cross-device authentication. The QR code flow does not appear. The user can only log in if they have a credential on this specific device. Your support team will see this case more than you expect.
- A user whose password manager is locked. 1Password and Bitwarden need to be unlocked before they can serve a passkey. If the user just opened their browser, the autofill prompt may not show their saved passkeys until they manually unlock their password manager. This is confusing and looks like the passkey is missing.
The pattern that helps: never assume a login attempt is final. Always offer at least two paths on the login page. "Sign in with passkey" and "Email me a sign-in link" side by side. The passkey path covers most cases. The email path covers the user who is on a new device, locked password manager, or weird policy environment. Forcing users into a single path is where the support tickets come from.
The other thing that helps: explicit copy. When the QR code flow triggers, do not just show the QR code. Tell the user "Use your phone to scan this code and approve the sign-in." Most users have never seen a WebAuthn cross-device flow and need a sentence to recognize what is happening.
What Breaks in the Wild
A list of real failures from real production rollouts. None of these are exotic.
Safari and the third-party cookie blocker. Safari's privacy mode in some configurations blocks the storage that holds the WebAuthn challenge if you store it in a cookie scoped wrong. If you are seeing intermittent challenge mismatch errors specifically on Safari, check that your session cookie has SameSite=Lax and is not getting blocked by intelligent tracking prevention. Storing the challenge server-side keyed by session ID dodges this entirely.
Subdomain credential split. A user registers a passkey on app.example.com because that is what the browser was on at the time. They later try to log in on example.com. The credential does not show up because the RP ID does not match. Fix: pick one canonical RP ID at the start, usually the registrable domain (example.com), and use it everywhere. Migrating later is painful.
Counter rollback. Some authenticators (notably some old YubiKeys) increment the signature counter on each authentication. Some (most platform authenticators today) do not, and the counter stays at zero. Your verification logic should accept both. A naive "counter must always increase" check rejects platform authenticator users intermittently.
The exclude list explosion. excludeCredentials is meant to prevent the user from registering the same authenticator twice. If a user has 12 credentials, you send 12 entries in the exclude list. Some authenticators handle this poorly and time out. Cap the exclude list at the user's most recently used credentials, or skip it entirely and dedupe on the server when you receive the registration response.
Resident key promises broken. You ask for residentKey: 'required' because you want discoverable credential flows. The user's authenticator does not support it. The browser silently registers a non-discoverable credential. The user's next login does not show their passkey in the autofill prompt because the credential is not discoverable. Fix: check the response's authenticatorAttachment and credentialDeviceType to see what you actually got, and surface a warning if the flow you wanted is not what was created.
Email-as-username collision with discoverable credentials. You designed your sign-in page to ask for an email first, then offer a passkey. Discoverable credential flow is a button labeled "Sign in with passkey" that bypasses the email entry. New users who open your sign-in page see two options and pick the wrong one. The fix is to combine: show the passkey button up front, and below it, the email input for users who do not have a passkey or want the magic-link path.
The Code That Holds Up
What I have ended up with after a few rounds of iteration, on the server side:
import {
generateRegistrationOptions,
verifyRegistrationResponse,
generateAuthenticationOptions,
verifyAuthenticationResponse,
} from '@simplewebauthn/server';
const RP = {
id: process.env.WEBAUTHN_RP_ID!,
name: process.env.WEBAUTHN_RP_NAME!,
origin: process.env.WEBAUTHN_ORIGIN!,
};
export async function startRegistration(user: User) {
const credentials = await db.credentials.findByUserId(user.id);
const options = await generateRegistrationOptions({
rpName: RP.name,
rpID: RP.id,
userID: new TextEncoder().encode(user.id),
userName: user.email,
attestationType: 'none',
excludeCredentials: credentials.slice(0, 10).map((c) => ({
id: c.credentialId,
transports: c.transports,
})),
authenticatorSelection: {
residentKey: 'preferred',
userVerification: 'preferred',
},
});
await sessionChallenges.set(user.id, options.challenge, { ttl: 300 });
return options;
}
export async function finishRegistration(user: User, response: RegistrationResponseJSON, label: string) {
const expectedChallenge = await sessionChallenges.get(user.id);
if (!expectedChallenge) throw new Error('challenge expired');
const verification = await verifyRegistrationResponse({
response,
expectedChallenge,
expectedOrigin: RP.origin,
expectedRPID: RP.id,
});
if (!verification.verified || !verification.registrationInfo) {
throw new Error('registration failed');
}
const info = verification.registrationInfo;
await db.credentials.create({
userId: user.id,
credentialId: info.credential.id,
publicKey: info.credential.publicKey,
signatureCounter: info.credential.counter,
transports: response.response.transports ?? [],
deviceLabel: label || `Device added ${new Date().toLocaleDateString()}`,
backupEligible: info.credentialBackedUp,
backupState: info.credentialBackedUp,
});
await sessionChallenges.delete(user.id);
}
The login side is the same shape with generateAuthenticationOptions and verifyAuthenticationResponse. The thing worth noting is that on a discoverable credential flow, you do not know which user is logging in until after you verify the assertion. So you look up the credential by credentialId first, then load the user, then verify. The order matters because verification needs the public key that belongs to that credential.
The session challenge storage is the unsexy part that is worth getting right. A short-lived TTL (five minutes is plenty) keyed by something stable for the request, and never reused. Reusing a challenge breaks the security model entirely. If you are tempted to write your own challenge storage, use Redis or your existing session store and move on.
For the broader auth library question of whether to build this yourself or pick a service like Clerk, Auth0, or Better Auth, the auth library comparison is worth reading. Most of the hosted providers now offer passkey support out of the box, with the same recovery and cross-device subtleties handled for you. The decision is the standard one: build for control and customization, buy for speed and offloaded support burden.
The Browser Compatibility Floor in 2026
A short matrix of where things actually work as of mid-2026:
- Safari 17+ supports passkeys, syncs through iCloud Keychain, supports the cross-device hybrid transport.
- Chrome 125+ supports passkeys on macOS, Windows, Linux, ChromeOS, and Android. Google Password Manager syncs across signed-in devices.
- Firefox 122+ supports the WebAuthn API but does not sync credentials itself. It defers to the OS-level platform authenticator on macOS and Windows. On Linux, the user's experience depends on whether they have a hardware authenticator plugged in.
- Edge follows Chrome.
- Mobile browsers all defer to the OS authenticator. iOS Safari uses iCloud Keychain. Android Chrome uses Google Password Manager. Both work well.
Conditional UI (the autofill prompt that shows passkeys without the user clicking anything) requires the page to call navigator.credentials.get() with mediation: 'conditional' and an <input autocomplete="username webauthn">. This works in Safari 16+, Chrome 108+, and Firefox 119+. The user experience is excellent when it lands. The fallback to a clicked button needs to exist for browsers that do not support it.
The compatibility story is in a much better place than it was even a year ago. The remaining gap is configuration, not capability. Corporate-managed environments are still where things break, and the gap between what the spec allows and what enterprise IT permits is the gap your support tickets will live in.
What I Would Tell My Past Self
Three things that would have saved me significant time on the first rollout.
The recovery story is the product. Spend more time on it than on the registration flow. Most engineering attention goes to "how do we make registration smooth" and not enough goes to "what happens when the user calls support saying their phone fell in a lake." The second one is what determines whether passkeys are a net win for your users or a way for them to get locked out.
Add passkey support without removing passwords first. Treat passwords as a legacy fallback, not a problem to eliminate. Letting users opt in incrementally and confirming their passkeys work across all their devices before any cleanup means the rollback path stays open. Removing passwords prematurely is how you generate a churn event.
Test on a corporate-managed Windows laptop. The flows that are smooth on a personal MacBook with iCloud Keychain are not necessarily smooth on a managed Windows device with a third-party password manager. The only way to know is to try, and ideally to ship a beta to a population that includes those users before you flip the default.
Passkeys are better than passwords for users who already have a sync mechanism set up. They are an improvement for users with one device. They are a regression for users you push into them without giving them a working recovery story. The technology is solid. The product work around it is where the wins and losses are.
If you are building auth from scratch in 2026 and want to skip most of this, the auth library comparison is the honest version of which providers handle the messy parts well. If you are extending an existing auth system, the SimpleWebAuthn library plus the schema above will get you to a working passkey flow in a week. Getting it to a flow that does not generate support tickets takes longer, and the difference is mostly the work described in this post.
The protocol is solved. The product is not. That is the gap worth budgeting for.
Top comments (1)
One thing that's easy to miss with synced credentials: your server-side revocation model doesn't actually remove the private key. If a user's iCloud or Google account gets compromised, the attacker has every synced passkey — and deleting the credential from your database only prevents future authentications on your end. The key material is still sitting in the attacker's sync store. Unlike session tokens where server-side invalidation is definitive, passkey revocation is asymmetric: you've stopped trusting the credential but you haven't destroyed it. The attacker can still present a valid signature if you ever re-register that credential ID or if another relying party accepts it. It's worth adding a
revokedAttimestamp to the credential table and treating any re-registration attempt for a revoked credential ID as suspicious rather than just a normal re-enrollment.