Implement Passkeys / WebAuthn (with code)
Identity · practitioner · 8 min read · last reviewed 2026-07-07
A working guide to passkeys and WebAuthn: the ceremony, real server and browser code, synced vs device-bound tradeoffs, and the recovery design that makes or breaks it.
TL;DR
- FIDO2 is the umbrella spec; WebAuthn is its browser API and CTAP is the authenticator protocol; a passkey is a discoverable WebAuthn credential, usually cloud-synced.
- The registration (attestation) and authentication (assertion) ceremonies use a key pair whose private key never leaves the authenticator, so a server breach leaks only public keys.
- WebAuthn binds every signature to the origin and a server-issued challenge, which is what makes it phishing-resistant by construction.
- Synced passkeys (iCloud, Google, 1Password) win for consumer recovery and adoption; device-bound roaming keys suit high-assurance systems but raise lockout risk.
- Use a maintained library such as @simplewebauthn/server and @simplewebauthn/browser rather than hand-rolling CBOR parsing and signature verification.
Passkeys and WebAuthn let a user sign in with a cryptographic key pair instead of a password, so there is no shared secret to phish, leak, or reuse. You generate a challenge on your server, the browser signs it with a private key held by the authenticator, and you verify the signature against a stored public key. This guide walks the ceremony, the code, and the parts that actually hurt: recovery and cross-ecosystem gaps.
Passkeys vs WebAuthn vs FIDO2: the terms, cleared up
These are three layers of the same thing, not competitors. FIDO2 is the umbrella spec set from the FIDO Alliance; it has two halves: WebAuthn (the browser/JavaScript API, standardized by the W3C) and CTAP (the protocol between the browser and an external authenticator). A passkey is the consumer-friendly name for a discoverable WebAuthn credential, usually one that syncs across a user's devices. So you implement WebAuthn, the platform gives you FIDO2 under the hood, and your users call the result a passkey. When someone says "add passkeys," they mean "call the WebAuthn API." See passkeys.dev for the canonical vocabulary.
How the ceremony works: attestation and assertion
WebAuthn runs two ceremonies, both built on public-key cryptography with no secret ever leaving the device. Registration (attestation) creates a new key pair: the authenticator generates a private key it never exports, and hands your server the matching public key plus a credential ID. Authentication (assertion) proves possession of that private key by signing a fresh, server-issued challenge.
The invariant that makes this phishing-resistant: the browser binds every signature to the origin (your actual domain) and to the challenge you issued. The private key stays in secure hardware (a TPM, Secure Enclave, or security key). Your server only ever stores public data, so a database breach leaks nothing an attacker can authenticate with. Compare that to password hashes, which are offline-crackable. For the broader tradeoff analysis, see the passwordless auth selection matrix.
Platform vs roaming, synced vs device-bound
Authenticators come in two form factors and two portability models, and the portability model is the real security decision. A platform authenticator is built into the device (Touch ID, Windows Hello, Android's fingerprint). A roaming authenticator is external and moves between devices (a YubiKey or other security key over USB, NFC, or Bluetooth).
Cutting across that: a passkey is either synced or device-bound. Synced passkeys (iCloud Keychain, Google Password Manager, 1Password) replicate the private key across the user's devices through an end-to-end-encrypted cloud. Device-bound passkeys never leave the hardware they were minted on. Synced passkeys are dramatically better for recovery and adoption; device-bound passkeys are stronger against a compromised cloud account but create lockout risk. For consumer apps, synced wins. For high-assurance internal systems, require a device-bound roaming key.
| Dimension | Synced passkey | Device-bound passkey |
|---|---|---|
| Portability | Follows the user across devices | Stuck on one device |
| Recovery if device lost | Automatic via cloud restore | Lost unless a backup key exists |
| Attack surface | Cloud account compromise | Physical possession only |
| Best fit | Consumer, broad rollout | Regulated, high-assurance |
| Signals | backupEligible, backupState flags | Neither flag set |
You can read the backupEligible and backupState authenticator flags server-side to know which kind you just registered, and enforce policy accordingly.
Implementation: challenge, create/get, and server verify
Do not hand-roll the crypto: use a maintained library and let it parse CBOR, validate the attestation, and check the signature. The examples below use @simplewebauthn/server and its browser companion, which are actively maintained and track spec changes. The four things you own are the relying-party ID, the challenge, storing credentials, and verifying responses.
The relying-party ID (RP ID) is your registrable domain, for example example.com. It must be equal to or a parent of the page origin, and credentials are scoped to it, so pick it once and keep it stable. Changing the RP ID orphans every existing passkey.
Generate registration options with a fresh, random challenge and persist it against the session:
import { generateRegistrationOptions } from '@simplewebauthn/server';
const options = await generateRegistrationOptions({
rpName: 'Example',
rpID: 'example.com',
userName: user.email,
attestationType: 'none',
authenticatorSelection: {
residentKey: 'required', // discoverable credential = a passkey
userVerification: 'preferred',
},
});
await session.set('regChallenge', options.challenge);
return options;On the client, pass those options straight into the browser helper, which wraps navigator.credentials.create and handles base64url encoding for you:
import { startRegistration } from '@simplewebauthn/browser';
const optionsJSON = await fetch('/webauthn/register/options').then(r => r.json());
const attResp = await startRegistration({ optionsJSON });
// POST attResp back to your server for verification
await fetch('/webauthn/register/verify', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(attResp),
});Verify server-side against the challenge and origin you issued, then store the returned public key and credential ID:
import { verifyRegistrationResponse } from '@simplewebauthn/server';
const { verified, registrationInfo } = await verifyRegistrationResponse({
response: attResp,
expectedChallenge: await session.get('regChallenge'),
expectedOrigin: 'https://example.com',
expectedRPID: 'example.com',
});
if (verified) {
await db.saveCredential(user.id, {
id: registrationInfo.credential.id,
publicKey: registrationInfo.credential.publicKey,
counter: registrationInfo.credential.counter,
backedUp: registrationInfo.credentialBackedUp,
});
}Authentication mirrors this: generateAuthenticationOptions issues a challenge, startAuthentication on the client calls navigator.credentials.get, and verifyAuthenticationResponse checks the signature against the stored public key. Reject any assertion whose signature counter went backward, and never accept a challenge you did not just issue. The MDN Web Authentication API reference documents every field if you need to go lower-level.
Account recovery, the hard part
Recovery is where passkey projects fail, so design it before you ship registration. A passkey is only as recoverable as the account behind it, and if a user's only credential lives on a lost, device-bound authenticator, you have locked them out permanently. Three defenses, in order of importance:
- Encourage synced passkeys. When the
backupStateflag is true, the credential already survives device loss through the platform cloud. This solves most consumer recovery for free. - Enroll multiple credentials. Prompt users to add a second passkey (a second device, or a roaming security key) right after the first succeeds. Two credentials on two devices removes the single point of failure.
- Keep a vetted fallback factor. An email or SMS magic link, a recovery code printed at enrollment, or a support-desk identity-proofing path. Treat the fallback as your weakest link and monitor it: attackers will target it instead of the passkey.
Do not treat "forgot password" reflexes as recovery. A reset email that re-enrolls a passkey with no identity check turns your phishing-resistant login into an email-phishing login. Gate re-enrollment behind the strongest factor the user still holds. The passwordless auth checklist has a full recovery-design section.
UX: conditional UI and progressive rollout
Lead with conditional UI (autofill) so passkeys feel like a shortcut, not a new step. Conditional UI lets the browser surface available passkeys directly in the username field's autofill dropdown, with no button click. You enable it by calling authentication with mediation: 'conditional' and adding autocomplete="username webauthn" to the input:
import { browserSupportsWebAuthnAutofill, startAuthentication } from '@simplewebauthn/browser';
if (await browserSupportsWebAuthnAutofill()) {
const optionsJSON = await fetch('/webauthn/auth/options').then(r => r.json());
const asseResp = await startAuthentication({ optionsJSON, useBrowserAutofill: true });
// POST asseResp for verification
}Roll out alongside passwords, not instead of them. Keep password login live, offer "create a passkey" as an opt-in after successful login, and only consider going passwordless-first once your passkey adoption and recovery paths are proven. Detect support with browserSupportsWebAuthn() and hide the option cleanly where it is missing. This is MFA that replaces the password rather than stacking on top of it, so measure drop-off at every prompt.
What this costs you
The price of passkeys is recovery complexity and cross-ecosystem friction, paid in engineering and support, not licensing. Budget honestly:
- Recovery is now your hardest surface. You traded password-reset tickets for account-recovery design, and a bad recovery flow silently reintroduces the phishing risk you removed.
- Cross-ecosystem gaps are real. A passkey created in iCloud Keychain does not sync to a Google account. A user on an iPhone and a Windows desktop may need a passkey on each, or must lean on cross-device flows (scanning a QR code over Bluetooth) that add steps.
- Attestation is mostly theater for consumer apps. Full attestation lets you enforce specific authenticator models, but it adds metadata management and privacy concerns. Use
attestationType: 'none'unless a compliance regime demands otherwise. - Support and telemetry. Instrument every ceremony outcome. Without funnel data you cannot tell a browser quirk from a UX failure.
None of this argues against passkeys. It argues for shipping them with recovery and rollout designed up front. For picking a managed provider that handles the ceremony plumbing, compare the top passwordless CIAM solutions.
Key takeaways
- Design account recovery before you ship registration; it is the single point where passkey projects fail and where phishing risk sneaks back in.
- Pick your relying-party ID once and keep it stable, because changing it orphans every existing passkey.
- Prompt users to enroll a second credential right after the first succeeds, so no account depends on one device.
- Ship passkeys alongside passwords with conditional UI autofill, and only go passwordless-first after adoption and recovery are proven.
- Use attestationType 'none' for consumer apps; full attestation is mostly overhead unless a compliance regime demands specific authenticator models.
- Read the backupEligible and backupState flags server-side to know whether you registered a synced or device-bound credential and enforce policy accordingly.
Frequently asked questions
- Are passkeys phishing-resistant?
- Yes. The browser binds each WebAuthn signature to the real page origin and to a fresh server-issued challenge, so a credential minted for example.com cannot be used on a lookalike domain, and there is no shared secret for a user to be tricked into typing.
- What if a user loses their device?
- With a synced passkey the credential restores automatically through the platform cloud (iCloud Keychain or Google Password Manager), so a new device just works. With a device-bound passkey the user is locked out unless they enrolled a second credential or you provide a vetted fallback factor, which is why multi-device enrollment matters.
- Do I need to store anything secret on my server?
- No. You store only the public key, the credential ID, and a signature counter. The private key never leaves the authenticator, so a database breach gives an attacker nothing they can authenticate with.
- Platform or roaming authenticator?
- A platform authenticator is built into the device (Touch ID, Windows Hello). A roaming authenticator is external and portable (a YubiKey over USB, NFC, or Bluetooth). Roaming keys move between devices and are common for high-assurance, device-bound requirements.
- Should I turn on attestation?
- For most consumer apps, no. Set attestationType to 'none'. Full attestation lets you enforce specific authenticator models but adds metadata handling and privacy considerations, so reserve it for compliance regimes that explicitly require it.
Related
Research pillars
Vendor comparisons
Go deeper
Sibling guides