Skip to main content
Security

Passkeys and WebAuthn: A Production Implementation Guide for FIDO2 Passwordless Authentication

10 min read
LD
Lucio Durán
Engineering Manager & AI Solutions Architect
Also available in: Español, Italiano

The Mental Model Most People Get Wrong

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.

The ceremony (yes, that's the actual spec term) works like this:

  1. 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.
  2. Authentication: Server sends a challenge + the credential ID. Browser finds the matching authenticator, which signs the challenge. Server verifies the signature.

That's it. No shared secret. No password hash. No TOTP seed. The private key never leaves the authenticator hardware.

Setting Up the Server: SimpleWebAuthn

The @simplewebauthn/server library is a practical choice to avoid parsing CBOR by hand. Here is the registration setup:

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

const rpName = 'My App';
const rpID = 'myapp.com';
const origin = `https://${rpID}`;

async function handleRegistrationStart(user) {
 // Fetch existing credentials so we can exclude them
 const existingCreds = await db.credentials.findByUserId(user.id);

 const options = await generateRegistrationOptions({
 rpName,
 rpID,
 userID: user.id,
 userName: user.email,
 // Prevent re-registering the same authenticator
 excludeCredentials: existingCreds.map(cred => ({
 id: cred.credentialID,
 type: 'public-key',
 transports: cred.transports,
 })),
 authenticatorSelection: {
 residentKey: 'required',
 userVerification: 'preferred',
 },
 // ES256 (P-256) first, then RS256 as fallback
 supportedAlgorithmIDs: [-7, -257],
 });

 // Store challenge in session — you MUST verify this later
 await sessionStore.set(user.id, {
 currentChallenge: options.challenge,
 });

 return options;
}

The 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.

The Client Side: Where Things Get Interesting

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

async function register() {
 // Get options from your server
 const optionsRes = await fetch('/api/auth/register/start', {
 method: 'POST',
 credentials: 'include',
 });
 const options = await optionsRes.json();

 let attestationResponse;
 try {
 attestationResponse = await startRegistration(options);
 } catch (err) {
 if (err.name === 'InvalidStateError') {
 // Authenticator already registered
 showError('This device is already registered.');
 return;
 }
 if (err.name === 'NotAllowedError') {
 // User cancelled or timed out
 showError('Registration was cancelled.');
 return;
 }
 throw err;
 }

 // Send attestation to server for verification
 const verifyRes = await fetch('/api/auth/register/finish', {
 method: 'POST',
 headers: { 'Content-Type': 'application/json' },
 body: JSON.stringify(attestationResponse),
 credentials: 'include',
 });

 const result = await verifyRes.json();
 if (result.verified) {
 showSuccess('Passkey registered!');
 }
}

The 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.

Verification: The Part You Can't Screw Up

async function handleRegistrationFinish(user, body) {
 const session = await sessionStore.get(user.id);
 const expectedChallenge = session.currentChallenge;

 const verification = await verifyRegistrationResponse({
 response: body,
 expectedChallenge,
 expectedOrigin: origin,
 expectedRPID: rpID,
 requireUserVerification: false, // 'preferred' means we can't require it
 });

 const { verified, registrationInfo } = verification;

 if (verified && registrationInfo) {
 const {
 credentialPublicKey,
 credentialID,
 counter,
 credentialDeviceType,
 credentialBackedUp,
 } = registrationInfo;

 await db.credentials.create({
 credentialID: Buffer.from(credentialID).toString('base64url'),
 credentialPublicKey: Buffer.from(credentialPublicKey),
 counter,
 transports: body.response.transports || [],
 userId: user.id,
 deviceType: credentialDeviceType, // 'singleDevice' or 'multiDevice'
 backedUp: credentialBackedUp, // synced to cloud?
 createdAt: new Date(),
 });
 }

 return { verified };
}

Notice 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.

Authentication Flow

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

async function handleAuthStart() {
 const options = await generateAuthenticationOptions({
 rpID,
 userVerification: 'preferred',
 // Empty allowCredentials = let the authenticator show all available passkeys
 allowCredentials: [],
 });

 // Store challenge — in prod, use a short-lived server-side store
 await challengeStore.set(options.challenge, {
 createdAt: Date.now(),
 expiresAt: Date.now() + 60000, // 60 seconds
 });

 return options;
}

async function handleAuthFinish(body) {
 const { id: credentialIDBase64 } = body;
 const credential = await db.credentials.findByCredentialID(credentialIDBase64);

 if (!credential) {
 throw new Error('Credential not found');
 }

 const challengeData = await challengeStore.get(body.response.clientDataJSON);
 // ... validate challenge hasn't expired ...

 const verification = await verifyAuthenticationResponse({
 response: body,
 expectedChallenge: challengeData.challenge,
 expectedOrigin: origin,
 expectedRPID: rpID,
 authenticator: {
 credentialID: Buffer.from(credential.credentialID, 'base64url'),
 credentialPublicKey: credential.credentialPublicKey,
 counter: credential.counter,
 },
 requireUserVerification: false,
 });

 if (verification.verified) {
 // Update the counter — this detects cloned authenticators
 await db.credentials.updateCounter(
 credential.credentialID,
 verification.authenticationInfo.newCounter
 );

 const user = await db.users.findById(credential.userId);
 return createSession(user);
 }
}

The 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.

Cross-Device Authentication: The Hybrid Transport

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:

  1. Desktop browser shows a QR code
  2. User scans it with their phone
  3. Phone and desktop establish a BLE tunnel
  4. Phone prompts for biometric authentication
  5. Signed assertion travels back through the tunnel to the desktop

You don't implement this yourself. The browser and OS handle the transport layer. But you need to know it exists because:

  • It's slow. The BLE handshake adds 3-8 seconds on top of the normal flow.
  • It's flaky. Bluetooth is Bluetooth. Analytics data shows it fails on about 12% of first attempts.
  • Users don't understand why their phone is buzzing when they're trying to log in on their laptop.

A 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."

The Attestation Question

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.

The practical recommendation, based on multiple production implementations: don't verify attestation unless legally required.

Attestation verification means:

  • Maintaining a root certificate store for every authenticator vendor
  • Handling certificate chain validation
  • Dealing with the FIDO Metadata Service (MDS) which is its own adventure
  • Some browsers will show scarier permission dialogs when attestation is requested

For 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.

The Migration: Passwords + Passkeys Coexistence

Passwords cannot simply be removed overnight. Here is a proven phased approach:

Phase 1: Add passkey registration in account settings. Keep password login as primary. Track adoption.

Phase 2: After login, prompt password-only users to register a passkey. Make it a gentle nudge, not a blocker.

Phase 3: Once a user has a passkey, make it the default login method. Show "Use password instead" as a secondary option.

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.

// Middleware: escalate to passkey for sensitive routes
async function requirePasskeyEscalation(req, res, next) {
 const user = req.user;
 const hasPasskey = await db.credentials.existsForUser(user.id);

 if (!hasPasskey) return next(); // no passkey = fallback to session

 const lastPasskeyAuth = req.session.lastPasskeyVerification;
 const fiveMinutesAgo = Date.now() - 5 * 60 * 1000;

 if (!lastPasskeyAuth || lastPasskeyAuth < fiveMinutesAgo) {
 return res.status(403).json({
 error: 'passkey_required',
 message: 'Please verify with your passkey to continue.',
 });
 }

 next();
}

Production Considerations

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.

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.

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.

// Conditional UI: passkey in the autofill dropdown
if (await PublicKeyCredential.isConditionalMediationAvailable()) {
 const options = await fetchAuthOptions();
 const assertion = await navigator.credentials.get({
 publicKey: options,
 mediation: 'conditional', // This is the magic
 });
 await verifyOnServer(assertion);
}

Metrics After 6 Months

After rolling this out to ~40k users in a representative production deployment:

  • 62% of active users registered at least one passkey
  • Average authentication time dropped from 14 seconds (email + password + 2FA) to 3 seconds
  • Account lockout tickets dropped 78%
  • Zero credential stuffing incidents (previously ~2/month hitting the WAF)

The 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.

Where This Is Heading

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.

Passkeys 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.

The 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.

passkeyswebauthnfido2passwordlessauthenticationsecuritycryptography

Tools mentioned in this article

Auth0Try Auth0
ClerkTry Clerk
Disclosure: Some links in this article are affiliate links. If you sign up through them, I may earn a commission at no extra cost to you. I only recommend tools I personally use and trust.
Seguime