Passkeys Are Ready. Here Is How to Add Them to Your App

# passkeys# webauthn# authentication# security
Passkeys Are Ready. Here Is How to Add Them to Your AppAlan West

A step-by-step guide to implementing passkey authentication with WebAuthn — registration, login, fallbacks, and UX best practices.

I finally added passkey support to a side project last month, and I'm kicking myself for not doing it sooner. The UX improvement is dramatic — users authenticate with a fingerprint or face scan instead of typing a password. Here's how to implement it from scratch.

What Are Passkeys?

Passkeys are the consumer-friendly name for WebAuthn/FIDO2 credentials. Instead of a password, the user's device generates a public-private key pair. The private key never leaves the device. Authentication is a cryptographic challenge-response — no shared secrets, nothing to phish.

The key insight: passkeys are synced across devices via iCloud Keychain (Apple), Google Password Manager (Android/Chrome), or Windows Hello. This solves the old "I registered my security key on my laptop but I'm on my phone" problem.

Browser Support in 2026

We're in great shape:

Browser Passkey Support Synced Passkeys
Chrome 118+ Yes Via Google Password Manager
Safari 16.4+ Yes Via iCloud Keychain
Firefox 122+ Yes Via third-party managers
Edge 118+ Yes Via Windows Hello / Google PM

Practically speaking, 95%+ of users on current browsers can use passkeys. The main gap is older Android devices without a biometric sensor, and Firefox on Linux (improving, but still rough).

Prerequisites

You'll need a server library. I'm using @simplewebauthn/server and @simplewebauthn/browser, which are well-maintained and follow the spec closely.

npm install @simplewebauthn/server @simplewebauthn/browser
Enter fullscreen mode Exit fullscreen mode

Your app also needs to be served over HTTPS (even in development — use a tool like mkcert for local certs). WebAuthn won't work over plain HTTP.

Step 1: Registration (Creating a Passkey)

Registration has three phases: generate options, create credential on client, verify on server.

Server: Generate Registration Options

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

const rpName = 'My App';
const rpID = 'myapp.com'; // Your domain
const origin = 'https://myapp.com';

app.post('/auth/passkey/register/options', async (req, res) => {
  const user = req.user; // Must be authenticated already

  // Get existing credentials to exclude (prevent duplicate registration)
  const existingCredentials = await db.getCredentialsForUser(user.id);

  const options = await generateRegistrationOptions({
    rpName,
    rpID,
    userID: new TextEncoder().encode(user.id),
    userName: user.email,
    userDisplayName: user.name || user.email,
    // Exclude existing credentials so user doesn't register the same device twice
    excludeCredentials: existingCredentials.map(cred => ({
      id: cred.credentialID,
      type: 'public-key',
    })),
    authenticatorSelection: {
      residentKey: 'preferred',        // Allow discoverable credentials
      userVerification: 'preferred',    // Biometric if available
    },
  });

  // Store the challenge for verification
  await db.storeChallenge(user.id, options.challenge);

  res.json(options);
});
Enter fullscreen mode Exit fullscreen mode

Client: Create Credential

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

async function registerPasskey() {
  // 1. Get options from server
  const optionsRes = await fetch('/auth/passkey/register/options', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
  });
  const options = await optionsRes.json();

  // 2. Trigger the browser's WebAuthn dialog
  let credential;
  try {
    credential = await startRegistration({ optionsJSON: options });
  } catch (err) {
    if (err.name === 'NotAllowedError') {
      // User cancelled the dialog
      return { error: 'Registration cancelled' };
    }
    throw err;
  }

  // 3. Send credential to server for verification
  const verifyRes = await fetch('/auth/passkey/register/verify', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(credential),
  });

  return verifyRes.json();
}
Enter fullscreen mode Exit fullscreen mode

Server: Verify Registration

app.post('/auth/passkey/register/verify', async (req, res) => {
  const user = req.user;
  const expectedChallenge = await db.getChallenge(user.id);

  try {
    const verification = await verifyRegistrationResponse({
      response: req.body,
      expectedChallenge,
      expectedOrigin: origin,
      expectedRPID: rpID,
    });

    if (verification.verified && verification.registrationInfo) {
      const { credential } = verification.registrationInfo;

      // Store the credential
      await db.storeCredential({
        userId: user.id,
        credentialID: credential.id,
        publicKey: Buffer.from(credential.publicKey),
        counter: credential.counter,
        deviceType: verification.registrationInfo.credentialDeviceType,
        backedUp: verification.registrationInfo.credentialBackedUp,
        createdAt: new Date(),
      });

      res.json({ verified: true });
    } else {
      res.status(400).json({ error: 'Verification failed' });
    }
  } catch (err) {
    res.status(400).json({ error: err.message });
  }
});
Enter fullscreen mode Exit fullscreen mode

Step 2: Authentication (Using a Passkey)

Authentication follows the same pattern: generate challenge, sign on client, verify on server.

Server: Generate Authentication Options

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

app.post('/auth/passkey/login/options', async (req, res) => {
  const { email } = req.body;

  // For discoverable credentials, you can omit allowCredentials
  // to let the user pick from any passkey on their device
  const options = await generateAuthenticationOptions({
    rpID,
    userVerification: 'preferred',
    // Optionally limit to specific user's credentials:
    // allowCredentials: userCredentials.map(c => ({ id: c.credentialID, type: 'public-key' })),
  });

  // Store challenge (use session or cache, keyed by a temporary ID)
  const challengeId = crypto.randomUUID();
  await db.storeChallenge(challengeId, options.challenge);

  res.json({ ...options, challengeId });
});
Enter fullscreen mode Exit fullscreen mode

Client: Authenticate

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

async function loginWithPasskey() {
  // 1. Get options
  const optionsRes = await fetch('/auth/passkey/login/options', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ email: emailInput.value }),
  });
  const options = await optionsRes.json();

  // 2. Trigger authentication
  let credential;
  try {
    credential = await startAuthentication({ optionsJSON: options });
  } catch (err) {
    if (err.name === 'NotAllowedError') {
      return { error: 'Authentication cancelled' };
    }
    throw err;
  }

  // 3. Verify on server
  const verifyRes = await fetch('/auth/passkey/login/verify', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({
      ...credential,
      challengeId: options.challengeId,
    }),
  });

  const result = await verifyRes.json();
  if (result.verified) {
    window.location.href = '/dashboard';
  }
}
Enter fullscreen mode Exit fullscreen mode

Server: Verify Authentication

app.post('/auth/passkey/login/verify', async (req, res) => {
  const { challengeId, ...credential } = req.body;
  const expectedChallenge = await db.getChallenge(challengeId);

  // Look up the credential
  const storedCredential = await db.getCredentialById(credential.id);
  if (!storedCredential) {
    return res.status(401).json({ error: 'Unknown credential' });
  }

  try {
    const verification = await verifyAuthenticationResponse({
      response: credential,
      expectedChallenge,
      expectedOrigin: origin,
      expectedRPID: rpID,
      credential: {
        id: storedCredential.credentialID,
        publicKey: storedCredential.publicKey,
        counter: storedCredential.counter,
      },
    });

    if (verification.verified) {
      // Update the counter (important for detecting cloned authenticators)
      await db.updateCredentialCounter(
        storedCredential.credentialID,
        verification.authenticationInfo.newCounter
      );

      // Create session
      req.session.userId = storedCredential.userId;

      res.json({ verified: true });
    } else {
      res.status(401).json({ error: 'Verification failed' });
    }
  } catch (err) {
    res.status(401).json({ error: err.message });
  }
});
Enter fullscreen mode Exit fullscreen mode

Step 3: Handling Fallbacks

Not every user can use passkeys. You need a fallback.

// Check if WebAuthn is available
function isPasskeySupported() {
  return !!(
    window.PublicKeyCredential &&
    typeof window.PublicKeyCredential === 'function'
  );
}

// Check if the platform supports conditional UI (autofill)
async function isConditionalUIAvailable() {
  if (!isPasskeySupported()) return false;
  return PublicKeyCredential.isConditionalMediationAvailable?.() ?? false;
}

// Build your login form accordingly
async function initLoginForm() {
  const supportsPasskeys = isPasskeySupported();
  const supportsAutofill = await isConditionalUIAvailable();

  if (supportsAutofill) {
    // Best UX: passkey autofill in the username field
    showAutofilledPasskeyLogin();
  } else if (supportsPasskeys) {
    // Good UX: explicit "Sign in with passkey" button
    showPasskeyButton();
  }

  // Always show email/password as fallback
  showEmailPasswordForm();
}
Enter fullscreen mode Exit fullscreen mode

Conditional UI (Passkey Autofill)

This is the smoothest UX. The browser shows passkey suggestions in the autofill dropdown, just like saved passwords:

<input
  type="text"
  id="email"
  autocomplete="username webauthn"
  placeholder="Email address"
/>
Enter fullscreen mode Exit fullscreen mode
// Start conditional (autofill) authentication
async function startConditionalAuth() {
  const optionsRes = await fetch('/auth/passkey/login/options', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
  });
  const options = await optionsRes.json();

  try {
    const credential = await startAuthentication({
      optionsJSON: options,
      useBrowserAutofill: true,
    });
    // User selected a passkey from autofill — verify it
    await verifyAndLogin(credential);
  } catch (err) {
    // User chose password instead, or no passkey available
    console.log('Conditional auth not completed:', err);
  }
}
Enter fullscreen mode Exit fullscreen mode

UX Best Practices

  1. Don't force passkeys. Offer them alongside existing auth methods. Let users discover and adopt them naturally.

  2. Prompt for registration after login. Once a user is authenticated (via password), show a "Add a passkey for faster login next time" prompt. Don't interrupt the signup flow.

  3. Show what's registered. Give users a settings page where they can see their registered passkeys and delete them:

// Settings page API
app.get('/auth/passkeys', async (req, res) => {
  const credentials = await db.getCredentialsForUser(req.user.id);
  res.json(credentials.map(c => ({
    id: c.credentialID,
    deviceType: c.deviceType,
    backedUp: c.backedUp,
    createdAt: c.createdAt,
    lastUsed: c.lastUsed,
  })));
});
Enter fullscreen mode Exit fullscreen mode
  1. Require at least one other auth method. Don't let a user's only login method be a passkey — if they lose access to their devices and their passkeys aren't synced, they're locked out.

  2. Communicate clearly. Many users don't know what a passkey is. Use language like "Sign in with your fingerprint or face" rather than "WebAuthn FIDO2 credential."

If You Don't Want to Build From Scratch

Implementing passkeys correctly involves a lot of edge cases: attestation formats, credential management, cross-device flows, account recovery. If you don't want to handle all of this yourself, tools like Authon and Clerk offer built-in passkey support that you can integrate in an afternoon. They handle the WebAuthn ceremony, credential storage, and fallback flows, so you can focus on your actual product.

That said, the @simplewebauthn library makes the from-scratch approach very manageable. I'd recommend trying it at least once to understand what's happening under the hood.

Common Pitfalls

  • Forgetting to update the counter. The signature counter prevents credential cloning. Always update it after verification.
  • Using localhost without HTTPS. WebAuthn requires a secure context. Use mkcert for local development.
  • Not handling NotAllowedError. This fires when the user cancels the dialog. Don't show an error — just do nothing.
  • Setting userVerification: 'required' globally. This excludes devices without biometrics. Use 'preferred' unless you have a specific security requirement.
  • Not testing on multiple platforms. The WebAuthn UX differs significantly between Chrome on Mac, Safari on iOS, and Chrome on Android. Test all of them.

Wrapping Up

Passkeys are the most significant improvement to web authentication in years. They're phishing-resistant, faster than passwords, and users genuinely prefer them once they try it. The spec is stable, browser support is excellent, and the developer tooling is mature.

If you've been putting off adding passkey support, now's the time. Start with registration for existing users, add the conditional UI for login, and keep email/password as a fallback. Your users will thank you.