{"schemaVersion":1,"id":"https://lucioduran.com/blog/passkeys-webauthn-passwordless-implementation","url":"https://lucioduran.com/blog/passkeys-webauthn-passwordless-implementation","title":"Passkeys and WebAuthn: A Production Implementation Guide for FIDO2 Passwordless Authentication","description":"A brutally honest walkthrough of implementing FIDO2 passkeys with WebAuthn, resident credentials, cross-device auth, attestation ceremonies, and all the edge cases that will ruin your weekend.","language":"en","datePublished":"2025-12-29","dateModified":null,"contentFormat":"text/markdown","content":"Security\n\n# Passkeys and WebAuthn: A Production Implementation Guide for FIDO2 Passwordless Authentication\n\n2025-12-2910 min read\n\nLucio Durán\nEngineering Manager & AI Solutions Architect\n\nAlso available in: [Español](<https://lucioduran.com/es/blog/passkeys-webauthn-passwordless-implementation>)\n\n## The Mental Model Most People Get Wrong\n\nEveryone explains WebAuthn as \"like SSH keys but for the web.\" That's close but misleading. SSH keys sit in your ` ~/.ssh ` directory and you manage them manually. Passkeys are managed by the platform, your OS, your browser, your password manager. You don't see the private key. You don't export it. You don't ` chmod 600 ` it.\n\nThe ceremony (yes, that's the actual spec term) works like this:\n\n1. **Registration**: Server sends a challenge. Browser asks the authenticator to create a key pair. The authenticator stores the private key and returns the public key + credential ID to the server.\n2. **Authentication**: Server sends a challenge + the credential ID. Browser finds the matching authenticator, which signs the challenge. Server verifies the signature.\n\nThat's it. No shared secret. No password hash. No TOTP seed. The private key never leaves the authenticator hardware.\n\n## Setting Up the Server: SimpleWebAuthn\n\nThe [` @simplewebauthn/server `](<https://simplewebauthn.dev/>) library is a practical choice to avoid parsing CBOR by hand. Here is the registration setup:\n\n```javascript\nimport {\n generateRegistrationOptions,\n verifyRegistrationResponse,\n} from '@simplewebauthn/server';\n\nconst rpName = 'My App';\nconst rpID = 'myapp.com';\nconst origin = `https://${rpID}`;\n\nasync function handleRegistrationStart(user) {\n // Fetch existing credentials so we can exclude them\n const existingCreds = await db.credentials.findByUserId(user.id);\n\n const options = await generateRegistrationOptions({\n rpName,\n rpID,\n userID: user.id,\n userName: user.email,\n // Prevent re-registering the same authenticator\n excludeCredentials: existingCreds.map(cred => ({\n id: cred.credentialID,\n type: 'public-key',\n transports: cred.transports,\n })),\n authenticatorSelection: {\n residentKey: 'required',\n userVerification: 'preferred',\n },\n // ES256 (P-256) first, then RS256 as fallback\n supportedAlgorithmIDs: [-7, -257],\n });\n\n // Store challenge in session, you MUST verify this later\n await sessionStore.set(user.id, {\n currentChallenge: options.challenge,\n });\n\n return options;\n}\n```\n\nThe ` residentKey: 'required' ` bit is crucial. That's what makes it a \"passkey\" rather than just a WebAuthn credential. Resident keys (also called discoverable credentials) are stored on the authenticator itself, meaning the user doesn't need to provide a username first, the authenticator can enumerate available credentials for the relying party.\n\n## The Client Side: Where Things Get Interesting\n\n```javascript\nimport { startRegistration } from '@simplewebauthn/browser';\n\nasync function register() {\n // Get options from your server\n const optionsRes = await fetch('/api/auth/register/start', {\n method: 'POST',\n credentials: 'include',\n });\n const options = await optionsRes.json();\n\n let attestationResponse;\n try {\n attestationResponse = await startRegistration(options);\n } catch (err) {\n if (err.name === 'InvalidStateError') {\n // Authenticator already registered\n showError('This device is already registered.');\n return;\n }\n if (err.name === 'NotAllowedError') {\n // User cancelled or timed out\n showError('Registration was cancelled.');\n return;\n }\n throw err;\n }\n\n // Send attestation to server for verification\n const verifyRes = await fetch('/api/auth/register/finish', {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify(attestationResponse),\n credentials: 'include',\n });\n\n const result = await verifyRes.json();\n if (result.verified) {\n showSuccess('Passkey registered!');\n }\n}\n```\n\nThe error handling here is not optional. ` NotAllowedError ` fires when the user dismisses the browser prompt, and failing to catch it gracefully results in a flood of bug reports from users who accidentally hit Escape.\n\n## Verification: The Part You Can't Screw Up\n\n```javascript\nasync function handleRegistrationFinish(user, body) {\n const session = await sessionStore.get(user.id);\n const expectedChallenge = session.currentChallenge;\n\n const verification = await verifyRegistrationResponse({\n response: body,\n expectedChallenge,\n expectedOrigin: origin,\n expectedRPID: rpID,\n requireUserVerification: false, // 'preferred' means we can't require it\n });\n\n const { verified, registrationInfo } = verification;\n\n if (verified && registrationInfo) {\n const {\n credentialPublicKey,\n credentialID,\n counter,\n credentialDeviceType,\n credentialBackedUp,\n } = registrationInfo;\n\n await db.credentials.create({\n credentialID: Buffer.from(credentialID).toString('base64url'),\n credentialPublicKey: Buffer.from(credentialPublicKey),\n counter,\n transports: body.response.transports || [],\n userId: user.id,\n deviceType: credentialDeviceType, // 'singleDevice' or 'multiDevice'\n backedUp: credentialBackedUp, // synced to cloud?\n createdAt: new Date(),\n });\n }\n\n return { verified };\n}\n```\n\nNotice ` credentialDeviceType ` and ` credentialBackedUp `. These are from the flags byte in the authenticator data and they tell you something important: whether this passkey is synced across devices. A ` multiDevice ` + ` backedUp: true ` credential lives in iCloud Keychain or Google Password Manager. A ` singleDevice ` + ` backedUp: false ` credential is locked to a hardware key. You might want different trust levels for each.\n\n## Authentication Flow\n\n```javascript\nimport {\n generateAuthenticationOptions,\n verifyAuthenticationResponse,\n} from '@simplewebauthn/server';\n\nasync function handleAuthStart() {\n const options = await generateAuthenticationOptions({\n rpID,\n userVerification: 'preferred',\n // Empty allowCredentials = let the authenticator show all available passkeys\n allowCredentials: [],\n });\n\n // Store challenge, in prod, use a short-lived server-side store\n await challengeStore.set(options.challenge, {\n createdAt: Date.now(),\n expiresAt: Date.now() + 60000, // 60 seconds\n });\n\n return options;\n}\n\nasync function handleAuthFinish(body) {\n const { id: credentialIDBase64 } = body;\n const credential = await db.credentials.findByCredentialID(credentialIDBase64);\n\n if (!credential) {\n throw new Error('Credential not found');\n }\n\n const challengeData = await challengeStore.get(body.response.clientDataJSON);\n // ... validate challenge hasn't expired ...\n\n const verification = await verifyAuthenticationResponse({\n response: body,\n expectedChallenge: challengeData.challenge,\n expectedOrigin: origin,\n expectedRPID: rpID,\n authenticator: {\n credentialID: Buffer.from(credential.credentialID, 'base64url'),\n credentialPublicKey: credential.credentialPublicKey,\n counter: credential.counter,\n },\n requireUserVerification: false,\n });\n\n if (verification.verified) {\n // Update the counter, this detects cloned authenticators\n await db.credentials.updateCounter(\n credential.credentialID,\n verification.authenticationInfo.newCounter\n );\n\n const user = await db.users.findById(credential.userId);\n return createSession(user);\n }\n}\n```\n\nThe empty ` allowCredentials ` array is the key to the passkey UX. When you pass an empty array, the browser shows a modal with all passkeys the user has for your origin. They pick one, authenticate with biometrics, done. No username field. No \"which account?\" dropdown. The authenticator handles it.\n\n## Cross-Device Authentication: The Hybrid Transport\n\nThis is where it gets gnarly. Say a user registered their passkey on their iPhone and now they're sitting at a Windows desktop. The FIDO2 spec defines a \"hybrid\" transport (formerly called caBLE, cloud-assisted Bluetooth Low Energy) that works like this:\n\n1. Desktop browser shows a QR code\n2. User scans it with their phone\n3. Phone and desktop establish a BLE tunnel\n4. Phone prompts for biometric authentication\n5. Signed assertion travels back through the tunnel to the desktop\n\nYou don't implement this yourself. The browser and OS handle the transport layer. But you need to know it exists because:\n\n- It's **slow**. The BLE handshake adds 3-8 seconds on top of the normal flow.\n- It's **flaky**. Bluetooth is Bluetooth. Analytics data shows it fails on about 12% of first attempts.\n- Users **don't understand** why their phone is buzzing when they're trying to log in on their laptop.\n\nA recommended approach: when a cross-device flow is likely (no platform authenticator available), display a brief explainer UI before triggering the WebAuthn ceremony. Something like \"This will connect to your phone via Bluetooth. Make sure your phone is nearby and unlocked.\"\n\n## The Attestation Question\n\nDuring registration, the authenticator can include an attestation statement, a cryptographic proof of the authenticator's make and model, signed by the manufacturer. The spec supports several formats: ` packed `, ` tpm `, ` android-key `, ` fido-u2f `, ` apple `, and ` none `.\n\nThe practical recommendation, based on multiple production implementations: **don't verify attestation unless legally required.**\n\nAttestation verification means:\n\n- Maintaining a root certificate store for every authenticator vendor\n- Handling certificate chain validation\n- Dealing with the FIDO Metadata Service (MDS) which is its own adventure\n- Some browsers will show scarier permission dialogs when attestation is requested\n\nFor 95% of applications, ` attestation: 'none' ` is the right choice. You still get the full cryptographic security of the challenge-response flow. You just don't know if it's a YubiKey 5C or a Pixel 8 built-in sensor, and for most use cases, you don't care.\n\n## The Migration: Passwords + Passkeys Coexistence\n\nPasswords cannot simply be removed overnight. Here is a proven phased approach:\n\n**Phase 1**: Add passkey registration in account settings. Keep password login as primary. Track adoption.\n\n**Phase 2**: After login, prompt password-only users to register a passkey. Make it a gentle nudge, not a blocker.\n\n**Phase 3**: Once a user has a passkey, make it the default login method. Show \"Use password instead\" as a secondary option.\n\n**Phase 4**: For users with passkeys, start requiring the passkey for sensitive operations (password change, email change, payment methods). This is the real security win.\n\n```javascript\n// Middleware: escalate to passkey for sensitive routes\nasync function requirePasskeyEscalation(req, res, next) {\n const user = req.user;\n const hasPasskey = await db.credentials.existsForUser(user.id);\n\n if (!hasPasskey) return next(); // no passkey = fallback to session\n\n const lastPasskeyAuth = req.session.lastPasskeyVerification;\n const fiveMinutesAgo = Date.now() - 5 * 60 * 1000;\n\n if (!lastPasskeyAuth || lastPasskeyAuth < fiveMinutesAgo) {\n return res.status(403).json({\n error: 'passkey_required',\n message: 'Please verify with your passkey to continue.',\n });\n }\n\n next();\n}\n```\n\n## Production Considerations\n\n**The RP ID problem**: Your RP ID is typically your domain. If you register passkeys on ` app.mysite.com `, they won't work on ` mysite.com ` or ` api.mysite.com `. Choose your RP ID carefully, you can set it to the registrable domain (` mysite.com `) which will work for all subdomains, but you can't change it later without invalidating all existing passkeys.\n\n**Counter semantics**: The spec says authenticators should increment a signature counter, and relying parties should reject assertions where the counter goes backward (cloned authenticator detection). In practice, synced passkeys often have a counter of 0 that never increments. SimpleWebAuthn handles this gracefully, but if you're rolling your own, don't treat a zero counter as suspicious.\n\n**Safari's opinion**: Safari on macOS will aggressively push users toward iCloud Keychain even when they have a hardware security key plugged in. The only way around this is conditional UI via ` PublicKeyCredential.isConditionalMediationAvailable() `, which lets you integrate passkey selection into the browser's autofill dropdown instead of the modal dialog.\n\n```javascript\n// Conditional UI: passkey in the autofill dropdown\nif (await PublicKeyCredential.isConditionalMediationAvailable()) {\n const options = await fetchAuthOptions();\n const assertion = await navigator.credentials.get({\n publicKey: options,\n mediation: 'conditional', // This is the magic\n });\n await verifyOnServer(assertion);\n}\n```\n\n## Metrics After 6 Months\n\nAfter rolling this out to ~40k users in a representative production deployment:\n\n- 62% of active users registered at least one passkey\n- Average authentication time dropped from 14 seconds (email + password + 2FA) to 3 seconds\n- Account lockout tickets dropped 78%\n- Zero credential stuffing incidents (previously ~2/month hitting the WAF)\n\nThe business impact of faster login is hard to overstate. Production data shows a measurable uptick in session frequency, users log in more when it doesn't feel like a chore.\n\n## Where This Is Heading\n\nThe spec is evolving fast. The WebAuthn Level 3 draft includes signal APIs for credential lifecycle management, things like telling the authenticator \"this credential has been revoked, please delete it.\" That's going to close the biggest remaining UX gap, where users accumulate stale passkeys they can't clean up.\n\nPasskeys aren't perfect. The cross-device flow is clunky. Account recovery is an unsolved problem that everyone handwaves. The vendor ecosystem is fragmented in annoying ways. But the security model is fundamentally sound, the UX is genuinely better for most users, and the ecosystem momentum is real. If you're still building new auth flows with passwords as the primary factor, you're building legacy code.\n\nThe recommended starting point is ` @simplewebauthn/server `, deploying passkeys as an optional second factor, measuring adoption, and iterating from there. The hard part is not the cryptography, it is the migration UX. And that is a much better problem to have than another credential stuffing incident at 3 AM.\n\n## Frequently asked questions\n\n### Can passkeys completely replace passwords today?\n\nFor most consumer apps, yes. The coverage across iOS 16+, Android 9+, and modern browsers is broad enough. Enterprise is trickier due to managed device policies and legacy LDAP integrations.\n\n### What happens when a user loses all their devices?\n\nThis is the hard problem. You need an account recovery flow, typically email magic link or SMS OTP as a fallback. Passkeys synced via iCloud Keychain or Google Password Manager mitigate single-device loss.\n\n### Is attestation worth verifying in production?\n\nFor most apps, no. Attestation tells you the authenticator model but adds complexity and privacy concerns. Only enforce it if you're in a regulated industry that mandates specific hardware (like banking with FIPS 140-2).\n\n## Tips\n\n1. **Tip:** Always set ` authenticatorAttachment: 'platform' ` first and fall back to ` 'cross-platform' `, users overwhelmingly prefer the built-in biometric over plugging in a hardware key.\n2. **Tip:** Store the ` credentialID ` as base64url, not raw bytes. Every database driver handles binary blobs differently and you *will* get bitten during migration.\n3. **Tip:** Set ` userVerification: 'preferred' ` instead of ` 'required' ` in production, some older authenticators silently fail on ` 'required' ` and your users just see a blank screen.\n4. **Tip:** Test cross-device flows on actual hardware. The Hybrid transport (caBLE) between Android and macOS behaves nothing like the Chrome DevTools emulator suggests.\n\n- passkeys\n- webauthn\n- fido2\n- passwordless\n- authentication\n- security\n- cryptography\n\n[Back to Blog](<https://lucioduran.com/blog>)","headings":[{"level":1,"text":"Passkeys and WebAuthn: A Production Implementation Guide for FIDO2 Passwordless Authentication","url":"https://lucioduran.com/blog/passkeys-webauthn-passwordless-implementation#title"},{"level":2,"text":"The Mental Model Most People Get Wrong","url":"https://lucioduran.com/blog/passkeys-webauthn-passwordless-implementation#the-mental-model-most-people-get-wrong"},{"level":2,"text":"Setting Up the Server: SimpleWebAuthn","url":"https://lucioduran.com/blog/passkeys-webauthn-passwordless-implementation#setting-up-the-server-simplewebauthn"},{"level":2,"text":"The Client Side: Where Things Get Interesting","url":"https://lucioduran.com/blog/passkeys-webauthn-passwordless-implementation#the-client-side-where-things-get-interesting"},{"level":2,"text":"Verification: The Part You Can't Screw Up","url":"https://lucioduran.com/blog/passkeys-webauthn-passwordless-implementation#verification-the-part-you-cant-screw-up"},{"level":2,"text":"Authentication Flow","url":"https://lucioduran.com/blog/passkeys-webauthn-passwordless-implementation#authentication-flow"},{"level":2,"text":"Cross-Device Authentication: The Hybrid Transport","url":"https://lucioduran.com/blog/passkeys-webauthn-passwordless-implementation#cross-device-authentication-the-hybrid-transport"},{"level":2,"text":"The Attestation Question","url":"https://lucioduran.com/blog/passkeys-webauthn-passwordless-implementation#the-attestation-question"},{"level":2,"text":"The Migration: Passwords + Passkeys Coexistence","url":"https://lucioduran.com/blog/passkeys-webauthn-passwordless-implementation#the-migration-passwords-passkeys-coexistence"},{"level":2,"text":"Production Considerations","url":"https://lucioduran.com/blog/passkeys-webauthn-passwordless-implementation#production-considerations"},{"level":2,"text":"Metrics After 6 Months","url":"https://lucioduran.com/blog/passkeys-webauthn-passwordless-implementation#metrics-after-6-months"},{"level":2,"text":"Where This Is Heading","url":"https://lucioduran.com/blog/passkeys-webauthn-passwordless-implementation#where-this-is-heading"},{"level":2,"text":"Frequently asked questions","url":"https://lucioduran.com/blog/passkeys-webauthn-passwordless-implementation#questions"},{"level":3,"text":"Can passkeys completely replace passwords today?","url":"https://lucioduran.com/blog/passkeys-webauthn-passwordless-implementation#question-1"},{"level":3,"text":"What happens when a user loses all their devices?","url":"https://lucioduran.com/blog/passkeys-webauthn-passwordless-implementation#question-2"},{"level":3,"text":"Is attestation worth verifying in production?","url":"https://lucioduran.com/blog/passkeys-webauthn-passwordless-implementation#question-3"},{"level":2,"text":"Tips","url":"https://lucioduran.com/blog/passkeys-webauthn-passwordless-implementation#article-tips"}],"sections":[{"heading":"Passkeys and WebAuthn: A Production Implementation Guide for FIDO2 Passwordless Authentication","level":1,"url":"https://lucioduran.com/blog/passkeys-webauthn-passwordless-implementation","content":"Security"},{"heading":"Passkeys and WebAuthn: A Production Implementation Guide for FIDO2 Passwordless Authentication","level":1,"url":"https://lucioduran.com/blog/passkeys-webauthn-passwordless-implementation#title","content":"2025-12-2910 min read\n\nLucio Durán\nEngineering Manager & AI Solutions Architect\n\nAlso available in: [Español](<https://lucioduran.com/es/blog/passkeys-webauthn-passwordless-implementation>)"},{"heading":"The Mental Model Most People Get Wrong","level":2,"url":"https://lucioduran.com/blog/passkeys-webauthn-passwordless-implementation#the-mental-model-most-people-get-wrong","content":"Everyone explains WebAuthn as \"like SSH keys but for the web.\" That's close but misleading. SSH keys sit in your ` ~/.ssh ` directory and you manage them manually. Passkeys are managed by the platform, your OS, your browser, your password manager. You don't see the private key. You don't export it. You don't ` chmod 600 ` it.\n\nThe ceremony (yes, that's the actual spec term) works like this:\n\n1. **Registration**: Server sends a challenge. Browser asks the authenticator to create a key pair. The authenticator stores the private key and returns the public key + credential ID to the server.\n2. **Authentication**: Server sends a challenge + the credential ID. Browser finds the matching authenticator, which signs the challenge. Server verifies the signature.\n\nThat's it. No shared secret. No password hash. No TOTP seed. The private key never leaves the authenticator hardware."},{"heading":"Setting Up the Server: SimpleWebAuthn","level":2,"url":"https://lucioduran.com/blog/passkeys-webauthn-passwordless-implementation#setting-up-the-server-simplewebauthn","content":"The [` @simplewebauthn/server `](<https://simplewebauthn.dev/>) library is a practical choice to avoid parsing CBOR by hand. Here is the registration setup:\n\n```javascript\nimport {\n generateRegistrationOptions,\n verifyRegistrationResponse,\n} from '@simplewebauthn/server';\n\nconst rpName = 'My App';\nconst rpID = 'myapp.com';\nconst origin = `https://${rpID}`;\n\nasync function handleRegistrationStart(user) {\n // Fetch existing credentials so we can exclude them\n const existingCreds = await db.credentials.findByUserId(user.id);\n\n const options = await generateRegistrationOptions({\n rpName,\n rpID,\n userID: user.id,\n userName: user.email,\n // Prevent re-registering the same authenticator\n excludeCredentials: existingCreds.map(cred => ({\n id: cred.credentialID,\n type: 'public-key',\n transports: cred.transports,\n })),\n authenticatorSelection: {\n residentKey: 'required',\n userVerification: 'preferred',\n },\n // ES256 (P-256) first, then RS256 as fallback\n supportedAlgorithmIDs: [-7, -257],\n });\n\n // Store challenge in session, you MUST verify this later\n await sessionStore.set(user.id, {\n currentChallenge: options.challenge,\n });\n\n return options;\n}\n```\n\nThe ` residentKey: 'required' ` bit is crucial. That's what makes it a \"passkey\" rather than just a WebAuthn credential. Resident keys (also called discoverable credentials) are stored on the authenticator itself, meaning the user doesn't need to provide a username first, the authenticator can enumerate available credentials for the relying party."},{"heading":"The Client Side: Where Things Get Interesting","level":2,"url":"https://lucioduran.com/blog/passkeys-webauthn-passwordless-implementation#the-client-side-where-things-get-interesting","content":"```javascript\nimport { startRegistration } from '@simplewebauthn/browser';\n\nasync function register() {\n // Get options from your server\n const optionsRes = await fetch('/api/auth/register/start', {\n method: 'POST',\n credentials: 'include',\n });\n const options = await optionsRes.json();\n\n let attestationResponse;\n try {\n attestationResponse = await startRegistration(options);\n } catch (err) {\n if (err.name === 'InvalidStateError') {\n // Authenticator already registered\n showError('This device is already registered.');\n return;\n }\n if (err.name === 'NotAllowedError') {\n // User cancelled or timed out\n showError('Registration was cancelled.');\n return;\n }\n throw err;\n }\n\n // Send attestation to server for verification\n const verifyRes = await fetch('/api/auth/register/finish', {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify(attestationResponse),\n credentials: 'include',\n });\n\n const result = await verifyRes.json();\n if (result.verified) {\n showSuccess('Passkey registered!');\n }\n}\n```\n\nThe error handling here is not optional. ` NotAllowedError ` fires when the user dismisses the browser prompt, and failing to catch it gracefully results in a flood of bug reports from users who accidentally hit Escape."},{"heading":"Verification: The Part You Can't Screw Up","level":2,"url":"https://lucioduran.com/blog/passkeys-webauthn-passwordless-implementation#verification-the-part-you-cant-screw-up","content":"```javascript\nasync function handleRegistrationFinish(user, body) {\n const session = await sessionStore.get(user.id);\n const expectedChallenge = session.currentChallenge;\n\n const verification = await verifyRegistrationResponse({\n response: body,\n expectedChallenge,\n expectedOrigin: origin,\n expectedRPID: rpID,\n requireUserVerification: false, // 'preferred' means we can't require it\n });\n\n const { verified, registrationInfo } = verification;\n\n if (verified && registrationInfo) {\n const {\n credentialPublicKey,\n credentialID,\n counter,\n credentialDeviceType,\n credentialBackedUp,\n } = registrationInfo;\n\n await db.credentials.create({\n credentialID: Buffer.from(credentialID).toString('base64url'),\n credentialPublicKey: Buffer.from(credentialPublicKey),\n counter,\n transports: body.response.transports || [],\n userId: user.id,\n deviceType: credentialDeviceType, // 'singleDevice' or 'multiDevice'\n backedUp: credentialBackedUp, // synced to cloud?\n createdAt: new Date(),\n });\n }\n\n return { verified };\n}\n```\n\nNotice ` credentialDeviceType ` and ` credentialBackedUp `. These are from the flags byte in the authenticator data and they tell you something important: whether this passkey is synced across devices. A ` multiDevice ` + ` backedUp: true ` credential lives in iCloud Keychain or Google Password Manager. A ` singleDevice ` + ` backedUp: false ` credential is locked to a hardware key. You might want different trust levels for each."},{"heading":"Authentication Flow","level":2,"url":"https://lucioduran.com/blog/passkeys-webauthn-passwordless-implementation#authentication-flow","content":"```javascript\nimport {\n generateAuthenticationOptions,\n verifyAuthenticationResponse,\n} from '@simplewebauthn/server';\n\nasync function handleAuthStart() {\n const options = await generateAuthenticationOptions({\n rpID,\n userVerification: 'preferred',\n // Empty allowCredentials = let the authenticator show all available passkeys\n allowCredentials: [],\n });\n\n // Store challenge, in prod, use a short-lived server-side store\n await challengeStore.set(options.challenge, {\n createdAt: Date.now(),\n expiresAt: Date.now() + 60000, // 60 seconds\n });\n\n return options;\n}\n\nasync function handleAuthFinish(body) {\n const { id: credentialIDBase64 } = body;\n const credential = await db.credentials.findByCredentialID(credentialIDBase64);\n\n if (!credential) {\n throw new Error('Credential not found');\n }\n\n const challengeData = await challengeStore.get(body.response.clientDataJSON);\n // ... validate challenge hasn't expired ...\n\n const verification = await verifyAuthenticationResponse({\n response: body,\n expectedChallenge: challengeData.challenge,\n expectedOrigin: origin,\n expectedRPID: rpID,\n authenticator: {\n credentialID: Buffer.from(credential.credentialID, 'base64url'),\n credentialPublicKey: credential.credentialPublicKey,\n counter: credential.counter,\n },\n requireUserVerification: false,\n });\n\n if (verification.verified) {\n // Update the counter, this detects cloned authenticators\n await db.credentials.updateCounter(\n credential.credentialID,\n verification.authenticationInfo.newCounter\n );\n\n const user = await db.users.findById(credential.userId);\n return createSession(user);\n }\n}\n```\n\nThe empty ` allowCredentials ` array is the key to the passkey UX. When you pass an empty array, the browser shows a modal with all passkeys the user has for your origin. They pick one, authenticate with biometrics, done. No username field. No \"which account?\" dropdown. The authenticator handles it."},{"heading":"Cross-Device Authentication: The Hybrid Transport","level":2,"url":"https://lucioduran.com/blog/passkeys-webauthn-passwordless-implementation#cross-device-authentication-the-hybrid-transport","content":"This is where it gets gnarly. Say a user registered their passkey on their iPhone and now they're sitting at a Windows desktop. The FIDO2 spec defines a \"hybrid\" transport (formerly called caBLE, cloud-assisted Bluetooth Low Energy) that works like this:\n\n1. Desktop browser shows a QR code\n2. User scans it with their phone\n3. Phone and desktop establish a BLE tunnel\n4. Phone prompts for biometric authentication\n5. Signed assertion travels back through the tunnel to the desktop\n\nYou don't implement this yourself. The browser and OS handle the transport layer. But you need to know it exists because:\n\n- It's **slow**. The BLE handshake adds 3-8 seconds on top of the normal flow.\n- It's **flaky**. Bluetooth is Bluetooth. Analytics data shows it fails on about 12% of first attempts.\n- Users **don't understand** why their phone is buzzing when they're trying to log in on their laptop.\n\nA recommended approach: when a cross-device flow is likely (no platform authenticator available), display a brief explainer UI before triggering the WebAuthn ceremony. Something like \"This will connect to your phone via Bluetooth. Make sure your phone is nearby and unlocked.\""},{"heading":"The Attestation Question","level":2,"url":"https://lucioduran.com/blog/passkeys-webauthn-passwordless-implementation#the-attestation-question","content":"During registration, the authenticator can include an attestation statement, a cryptographic proof of the authenticator's make and model, signed by the manufacturer. The spec supports several formats: ` packed `, ` tpm `, ` android-key `, ` fido-u2f `, ` apple `, and ` none `.\n\nThe practical recommendation, based on multiple production implementations: **don't verify attestation unless legally required.**\n\nAttestation verification means:\n\n- Maintaining a root certificate store for every authenticator vendor\n- Handling certificate chain validation\n- Dealing with the FIDO Metadata Service (MDS) which is its own adventure\n- Some browsers will show scarier permission dialogs when attestation is requested\n\nFor 95% of applications, ` attestation: 'none' ` is the right choice. You still get the full cryptographic security of the challenge-response flow. You just don't know if it's a YubiKey 5C or a Pixel 8 built-in sensor, and for most use cases, you don't care."},{"heading":"The Migration: Passwords + Passkeys Coexistence","level":2,"url":"https://lucioduran.com/blog/passkeys-webauthn-passwordless-implementation#the-migration-passwords-passkeys-coexistence","content":"Passwords cannot simply be removed overnight. Here is a proven phased approach:\n\n**Phase 1**: Add passkey registration in account settings. Keep password login as primary. Track adoption.\n\n**Phase 2**: After login, prompt password-only users to register a passkey. Make it a gentle nudge, not a blocker.\n\n**Phase 3**: Once a user has a passkey, make it the default login method. Show \"Use password instead\" as a secondary option.\n\n**Phase 4**: For users with passkeys, start requiring the passkey for sensitive operations (password change, email change, payment methods). This is the real security win.\n\n```javascript\n// Middleware: escalate to passkey for sensitive routes\nasync function requirePasskeyEscalation(req, res, next) {\n const user = req.user;\n const hasPasskey = await db.credentials.existsForUser(user.id);\n\n if (!hasPasskey) return next(); // no passkey = fallback to session\n\n const lastPasskeyAuth = req.session.lastPasskeyVerification;\n const fiveMinutesAgo = Date.now() - 5 * 60 * 1000;\n\n if (!lastPasskeyAuth || lastPasskeyAuth < fiveMinutesAgo) {\n return res.status(403).json({\n error: 'passkey_required',\n message: 'Please verify with your passkey to continue.',\n });\n }\n\n next();\n}\n```"},{"heading":"Production Considerations","level":2,"url":"https://lucioduran.com/blog/passkeys-webauthn-passwordless-implementation#production-considerations","content":"**The RP ID problem**: Your RP ID is typically your domain. If you register passkeys on ` app.mysite.com `, they won't work on ` mysite.com ` or ` api.mysite.com `. Choose your RP ID carefully, you can set it to the registrable domain (` mysite.com `) which will work for all subdomains, but you can't change it later without invalidating all existing passkeys.\n\n**Counter semantics**: The spec says authenticators should increment a signature counter, and relying parties should reject assertions where the counter goes backward (cloned authenticator detection). In practice, synced passkeys often have a counter of 0 that never increments. SimpleWebAuthn handles this gracefully, but if you're rolling your own, don't treat a zero counter as suspicious.\n\n**Safari's opinion**: Safari on macOS will aggressively push users toward iCloud Keychain even when they have a hardware security key plugged in. The only way around this is conditional UI via ` PublicKeyCredential.isConditionalMediationAvailable() `, which lets you integrate passkey selection into the browser's autofill dropdown instead of the modal dialog.\n\n```javascript\n// Conditional UI: passkey in the autofill dropdown\nif (await PublicKeyCredential.isConditionalMediationAvailable()) {\n const options = await fetchAuthOptions();\n const assertion = await navigator.credentials.get({\n publicKey: options,\n mediation: 'conditional', // This is the magic\n });\n await verifyOnServer(assertion);\n}\n```"},{"heading":"Metrics After 6 Months","level":2,"url":"https://lucioduran.com/blog/passkeys-webauthn-passwordless-implementation#metrics-after-6-months","content":"After rolling this out to ~40k users in a representative production deployment:\n\n- 62% of active users registered at least one passkey\n- Average authentication time dropped from 14 seconds (email + password + 2FA) to 3 seconds\n- Account lockout tickets dropped 78%\n- Zero credential stuffing incidents (previously ~2/month hitting the WAF)\n\nThe business impact of faster login is hard to overstate. Production data shows a measurable uptick in session frequency, users log in more when it doesn't feel like a chore."},{"heading":"Where This Is Heading","level":2,"url":"https://lucioduran.com/blog/passkeys-webauthn-passwordless-implementation#where-this-is-heading","content":"The spec is evolving fast. The WebAuthn Level 3 draft includes signal APIs for credential lifecycle management, things like telling the authenticator \"this credential has been revoked, please delete it.\" That's going to close the biggest remaining UX gap, where users accumulate stale passkeys they can't clean up.\n\nPasskeys aren't perfect. The cross-device flow is clunky. Account recovery is an unsolved problem that everyone handwaves. The vendor ecosystem is fragmented in annoying ways. But the security model is fundamentally sound, the UX is genuinely better for most users, and the ecosystem momentum is real. If you're still building new auth flows with passwords as the primary factor, you're building legacy code.\n\nThe recommended starting point is ` @simplewebauthn/server `, deploying passkeys as an optional second factor, measuring adoption, and iterating from there. The hard part is not the cryptography, it is the migration UX. And that is a much better problem to have than another credential stuffing incident at 3 AM."},{"heading":"Frequently asked questions","level":2,"url":"https://lucioduran.com/blog/passkeys-webauthn-passwordless-implementation#questions","content":""},{"heading":"Can passkeys completely replace passwords today?","level":3,"url":"https://lucioduran.com/blog/passkeys-webauthn-passwordless-implementation#question-1","content":"For most consumer apps, yes. The coverage across iOS 16+, Android 9+, and modern browsers is broad enough. Enterprise is trickier due to managed device policies and legacy LDAP integrations."},{"heading":"What happens when a user loses all their devices?","level":3,"url":"https://lucioduran.com/blog/passkeys-webauthn-passwordless-implementation#question-2","content":"This is the hard problem. You need an account recovery flow, typically email magic link or SMS OTP as a fallback. Passkeys synced via iCloud Keychain or Google Password Manager mitigate single-device loss."},{"heading":"Is attestation worth verifying in production?","level":3,"url":"https://lucioduran.com/blog/passkeys-webauthn-passwordless-implementation#question-3","content":"For most apps, no. Attestation tells you the authenticator model but adds complexity and privacy concerns. Only enforce it if you're in a regulated industry that mandates specific hardware (like banking with FIPS 140-2)."},{"heading":"Tips","level":2,"url":"https://lucioduran.com/blog/passkeys-webauthn-passwordless-implementation#article-tips","content":"1. **Tip:** Always set ` authenticatorAttachment: 'platform' ` first and fall back to ` 'cross-platform' `, users overwhelmingly prefer the built-in biometric over plugging in a hardware key.\n2. **Tip:** Store the ` credentialID ` as base64url, not raw bytes. Every database driver handles binary blobs differently and you *will* get bitten during migration.\n3. **Tip:** Set ` userVerification: 'preferred' ` instead of ` 'required' ` in production, some older authenticators silently fail on ` 'required' ` and your users just see a blank screen.\n4. **Tip:** Test cross-device flows on actual hardware. The Hybrid transport (caBLE) between Android and macOS behaves nothing like the Chrome DevTools emulator suggests.\n\n- passkeys\n- webauthn\n- fido2\n- passwordless\n- authentication\n- security\n- cryptography\n\n[Back to Blog](<https://lucioduran.com/blog>)"}],"links":[{"text":"Español","url":"https://lucioduran.com/es/blog/passkeys-webauthn-passwordless-implementation"},{"text":"@simplewebauthn/server","url":"https://simplewebauthn.dev/"},{"text":"Back to Blog","url":"https://lucioduran.com/blog"}],"structuredData":[{"@context":"https://schema.org","@graph":[{"@type":"BlogPosting","@id":"https://lucioduran.com/blog/passkeys-webauthn-passwordless-implementation#article","mainEntityOfPage":{"@type":"WebPage","@id":"https://lucioduran.com/blog/passkeys-webauthn-passwordless-implementation"},"headline":"Passkeys and WebAuthn: A Production Implementation Guide for FIDO2 Passwordless Authentication","description":"A brutally honest walkthrough of implementing FIDO2 passkeys with WebAuthn, resident credentials, cross-device auth, attestation ceremonies, and all the edge cases that will ruin your weekend.","datePublished":"2025-12-29","author":{"@type":"Person","@id":"https://lucioduran.com/#person","name":"Lucio Durán","url":"https://lucioduran.com"},"publisher":{"@type":"Person","@id":"https://lucioduran.com/#person","name":"Lucio Durán","url":"https://lucioduran.com"},"image":"https://lucioduran.com/favicons/og-default.png","url":"https://lucioduran.com/blog/passkeys-webauthn-passwordless-implementation","inLanguage":"en","isPartOf":{"@id":"https://lucioduran.com/blog#blog"},"articleSection":"Security","keywords":"passkeys, webauthn, fido2, passwordless, authentication, security, cryptography","wordCount":1775,"timeRequired":"PT10M","workTranslation":[{"@type":"BlogPosting","inLanguage":"es","url":"https://lucioduran.com/es/blog/passkeys-webauthn-passwordless-implementation"}]},{"@type":"BreadcrumbList","itemListElement":[{"@type":"ListItem","position":1,"name":"Home","item":"https://lucioduran.com/"},{"@type":"ListItem","position":2,"name":"Blog","item":"https://lucioduran.com/blog"},{"@type":"ListItem","position":3,"name":"Passkeys and WebAuthn: A Production Implementation Guide for FIDO2 Passwordless Authentication","item":"https://lucioduran.com/blog/passkeys-webauthn-passwordless-implementation"}]},{"@type":"FAQPage","mainEntity":[{"@type":"Question","name":"Can passkeys completely replace passwords today?","acceptedAnswer":{"@type":"Answer","text":"For most consumer apps, yes. The coverage across iOS 16+, Android 9+, and modern browsers is broad enough. Enterprise is trickier due to managed device policies and legacy LDAP integrations."}},{"@type":"Question","name":"What happens when a user loses all their devices?","acceptedAnswer":{"@type":"Answer","text":"This is the hard problem. You need an account recovery flow, typically email magic link or SMS OTP as a fallback. Passkeys synced via iCloud Keychain or Google Password Manager mitigate single-device loss."}},{"@type":"Question","name":"Is attestation worth verifying in production?","acceptedAnswer":{"@type":"Answer","text":"For most apps, no. Attestation tells you the authenticator model but adds complexity and privacy concerns. Only enforce it if you're in a regulated industry that mandates specific hardware (like banking with FIPS 140-2)."}}]}]}],"contentHash":"d0a29226721675a034e789697302df3df9a89ce02dca10a28c04d2303c5890e6","provenance":{"method":"Deterministic serialization of the server-rendered main element","source":"https://lucioduran.com/blog/passkeys-webauthn-passwordless-implementation","hashAlgorithm":"sha256","hashScope":"JSON document fields before contentHash and provenance","sourceHTMLBytes":70546,"contentBytes":15778}}
