LUNAROPS · OPERATIONAL UPLINK 100% UPTIME 1,247d POSTS 893 JEFF.MOON@LUNAROPS.DEV UTC --:--:--

Passkeys and WebAuthn in Practice: A Developer's Complete Guide

webauthnpasskeysfido2authenticationsecuritypasswordlessjavascriptidentity

Passwords are a solved problem in the sense that everyone agrees they are terrible and nobody has successfully replaced them at scale — until now. By mid-2026, Microsoft has auto-enabled passkeys for millions of accounts, 87% of enterprises report active passkey deployments, and browser support has stabilised across Chrome, Firefox, Safari, and Edge to the point where conditional UI is reliable. The technology is no longer experimental. If you are building authentication today and you are not implementing passkeys, you are choosing to build something you will need to replace.

This post is a technical walkthrough, not a marketing summary. It covers how the cryptography actually works, the registration and authentication ceremonies step by step, a working server implementation using SimpleWebAuthn, what attestation actually means and when you need it, fallback strategy, and the UX decisions that separate deployments people use from deployments people abandon.


The Standards Stack

Before touching code, the terminology needs to be exact because “passkey” gets used to mean three different things depending on who’s talking.

┌─────────────────────────────────────────────────────────────┐
│                        FIDO2                                 │
│                                                             │
│   ┌──────────────────────────┐  ┌────────────────────────┐  │
│   │  WebAuthn (W3C)          │  │  CTAP2 (FIDO Alliance) │  │
│   │  Browser ↔ Server API    │  │  Browser ↔ Authenticator│  │
│   └──────────────────────────┘  └────────────────────────┘  │
└─────────────────────────────────────────────────────────────┘

Passkey = a FIDO2 credential that may be synced across devices
          (synced passkeys) or bound to one device (device-bound)
  • FIDO2 is the overarching standard. It combines two sub-specifications.
  • WebAuthn (Web Authentication API) is the W3C spec that defines how browsers expose authentication to JavaScript and how relying party servers verify credentials.
  • CTAP2 (Client-to-Authenticator Protocol 2) defines how the browser communicates with authenticators — hardware security keys, biometric sensors, TPMs.
  • Passkey is a branding term for a FIDO2 credential with good UX. Technically it means a discoverable credential (one the authenticator can return without being prompted with a credential ID). Synced passkeys are backed up to iCloud Keychain, Google Password Manager, 1Password, etc. Device-bound passkeys stay on the hardware they were created on (a YubiKey, a laptop’s TPM).

The cryptography is the same in all cases. The difference is where the private key lives and whether it can be copied.


How the Cryptography Works

A passkey is an asymmetric key pair generated on the authenticator. The private key never leaves the authenticator in plaintext. The public key is sent to and stored by the relying party (your server). Authentication proves possession of the private key by signing a server-issued challenge.

Registration:
┌────────────┐         ┌──────────────┐        ┌─────────────┐
│  Browser   │         │ Authenticator│        │   Server    │
└─────┬──────┘         └──────┬───────┘        └──────┬──────┘
      │  navigator.credentials│                        │
      │  .create(options)      │                        │
      ├──────────────────────►│                        │
      │  User verification    │                        │
      │  (biometric/PIN)      │                        │
      │  Generate key pair    │                        │
      │  Return pubkey +      │                        │
      │  attestation          │                        │
      │◄──────────────────────┤                        │
      │  POST /register/finish│                        │
      ├───────────────────────┼───────────────────────►│
      │                       │  Verify, store pubkey  │
      │                       │  + credentialId        │

Authentication:
      │  GET /login/options   │                        │
      ├───────────────────────┼───────────────────────►│
      │◄──────────────────────┼──── challenge + creds ─┤
      │  navigator.credentials│                        │
      │  .get(options)         │                        │
      ├──────────────────────►│                        │
      │  User verification    │                        │
      │  Sign challenge with  │                        │
      │  private key          │                        │
      │◄──────────────────────┤                        │
      │  POST /login/finish   │                        │
      ├───────────────────────┼───────────────────────►│
      │                       │  Verify signature with │
      │                       │  stored public key     │

The security guarantee comes from three properties:

  1. The private key never leaves the authenticator. For hardware-backed keys (TPM, Secure Enclave, YubiKey), the key cannot be extracted even by the operating system. For synced passkeys, the key is encrypted before syncing and only decrypts on an enrolled device.
  2. The challenge is bound to origin and RP ID. The authenticator includes the origin in the signed data. A phishing site at my-bank-login.evil.com cannot replay a legitimate response from bank.com — the origin in the signed assertion won’t match. This is the fundamental phishing resistance that passwords don’t have.
  3. No shared secret touches the network. Unlike passwords or OTPs, what travels over the wire (the challenge response) is a signature — useless to an attacker who intercepts it.

The RP ID and origin binding

This is the most common source of implementation bugs. The Relying Party ID is a domain suffix that must match the effective domain of every origin that can use the credential. If you set rpId: "example.com", then app.example.com, login.example.com, and example.com itself can all authenticate against it. If you set rpId: "app.example.com", only app.example.com works.

The browser enforces this at navigator.credentials.get() time — it compares the RP ID you pass to the current origin’s effective domain, and refuses if they don’t match. This is not a soft check. Once credentials are created against an RP ID, changing the RP ID orphans all existing credentials. There is no migration path. Get this right before you issue any credentials to users.

Example:
  rpId: "example.com"       ✓ works from: example.com, app.example.com
  rpId: "app.example.com"   ✗ fails from: example.com (too broad)
                             ✓ works from: app.example.com

The Registration Ceremony

Registration creates a credential. The flow has two halves: the server generates options and a challenge, the client calls navigator.credentials.create(), and the server verifies the response.

Server: generate registration options

Using @simplewebauthn/server (Node.js):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
import { generateRegistrationOptions } from "@simplewebauthn/server";

async function beginRegistration(userId: string, username: string) {
  // Fetch any credentials this user already has (to exclude them)
  const existingCredentials = await db.credentials.findByUserId(userId);

  const options = await generateRegistrationOptions({
    rpName: "My App",
    rpID: "example.com",
    userID: userId,
    userName: username,
    timeout: 60000,
    attestationType: "none",    // see attestation section below
    excludeCredentials: existingCredentials.map(c => ({
      id: c.credentialId,
      type: "public-key",
      transports: c.transports,
    })),
    authenticatorSelection: {
      residentKey: "required",   // required for discoverable credentials / passkeys
      userVerification: "required",
    },
    supportedAlgorithmIDs: [-7, -257],  // ES256, RS256
  });

  // Store the challenge — must be verified in the next step
  await cache.set(`challenge:${userId}`, options.challenge, { ttl: 60 });

  return options;
}

residentKey: "required" is what makes this a passkey rather than a classic FIDO2 credential. A resident key (discoverable credential) is stored on the authenticator indexed by RP ID and user handle, so the authenticator can return it during authentication without being told which credential to use. Without this, the server has to tell the authenticator which credential ID to try, which requires knowing who the user is before they authenticate — which requires a username, which defeats much of the UX benefit.

userVerification: "required" means the authenticator must verify the user locally (biometric or PIN) before releasing the credential. Setting this to "preferred" allows the credential to be used without user verification, which weakens the security model to something closer to a cookie.

Client: call the WebAuthn API

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
import { startRegistration } from "@simplewebauthn/browser";

async function register() {
  // Get options from the server
  const options = await fetch("/api/auth/register/begin").then(r => r.json());

  let credential;
  try {
    credential = await startRegistration(options);
  } catch (err) {
    if (err.name === "InvalidStateError") {
      // Credential already exists for this authenticator
      showError("A passkey already exists on this device.");
    } else if (err.name === "NotAllowedError") {
      // User cancelled or timed out — not an error to log server-side
      return;
    } else {
      throw err;
    }
  }

  // Send the response to the server
  const result = await fetch("/api/auth/register/finish", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify(credential),
  }).then(r => r.json());

  if (result.verified) {
    showSuccess("Passkey registered.");
  }
}

Server: verify registration response

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
import { verifyRegistrationResponse } from "@simplewebauthn/server";

async function finishRegistration(userId: string, body: RegistrationResponseJSON) {
  const expectedChallenge = await cache.get(`challenge:${userId}`);
  if (!expectedChallenge) throw new Error("Challenge expired or not found");

  const verification = await verifyRegistrationResponse({
    response: body,
    expectedChallenge,
    expectedOrigin: "https://example.com",
    expectedRPID: "example.com",
    requireUserVerification: true,
  });

  if (!verification.verified || !verification.registrationInfo) {
    throw new Error("Registration verification failed");
  }

  const { credentialID, credentialPublicKey, counter, credentialDeviceType } =
    verification.registrationInfo;

  // Store the credential
  await db.credentials.create({
    userId,
    credentialId: credentialID,
    publicKey: credentialPublicKey,     // stored as bytes
    counter,                            // replay protection
    deviceType: credentialDeviceType,   // "singleDevice" or "multiDevice"
    transports: body.response.transports ?? [],
  });

  await cache.del(`challenge:${userId}`);
  return { verified: true };
}

The counter field deserves attention. Each use of a hardware credential increments a counter stored on the authenticator. If the server sees a counter value less than or equal to what it has stored, a credential may have been cloned. Synced passkeys set the counter to 0 and always return 0 — cloning detection does not apply to them (the sync provider handles integrity). Your code should handle this gracefully: reject if counter < stored (definite replay), and flag but allow if counter === stored for non-hardware credentials.


The Authentication Ceremony

Authentication verifies that the user possesses the private key corresponding to a stored public key, with fresh proof via a signed challenge.

Server: generate authentication options

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
import { generateAuthenticationOptions } from "@simplewebauthn/server";

async function beginAuthentication(userId?: string) {
  // For passkey (usernameless) login, userId may be absent
  const allowCredentials = userId
    ? (await db.credentials.findByUserId(userId)).map(c => ({
        id: c.credentialId,
        type: "public-key" as const,
        transports: c.transports,
      }))
    : [];  // empty = let the authenticator choose (conditional UI / autofill)

  const options = await generateAuthenticationOptions({
    rpID: "example.com",
    timeout: 60000,
    allowCredentials,
    userVerification: "required",
  });

  // Store challenge without knowing the user yet (for usernameless flow)
  await cache.set(`auth-challenge:${options.challenge}`, options.challenge, { ttl: 60 });

  return options;
}

Client: conditional UI (the passkey autofill)

The biggest UX win in 2026 is conditional mediation — the browser shows passkeys in the standard autofill dropdown when the user focuses a username or email field, without any extra interaction. The user sees their saved passkeys alongside saved passwords and can tap one.

1
2
3
4
5
6
<input
  type="text"
  id="username"
  autocomplete="username webauthn"
  placeholder="Email or username"
/>
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
import { startAuthentication } from "@simplewebauthn/browser";
import { browserSupportsWebAuthnAutofill } from "@simplewebauthn/browser";

async function setupConditionalUI() {
  if (!(await browserSupportsWebAuthnAutofill())) return;

  const options = await fetch("/api/auth/login/begin").then(r => r.json());

  try {
    // useBrowserAutofill: true tells SimpleWebAuthn to use conditional mediation
    const credential = await startAuthentication(options, true);
    await completeAuthentication(credential);
  } catch (err) {
    if (err.name !== "NotAllowedError") throw err;
    // User dismissed the autofill — normal, no action needed
  }
}

// Call on page load — the autofill prompt appears lazily when the user
// focuses the username field, not immediately
setupConditionalUI();

Server: verify authentication response

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
import { verifyAuthenticationResponse } from "@simplewebauthn/server";

async function finishAuthentication(body: AuthenticationResponseJSON) {
  // Find the credential by its ID
  const credential = await db.credentials.findByCredentialId(body.id);
  if (!credential) throw new Error("Credential not found");

  const expectedChallenge = await cache.get(`auth-challenge:${/* from body */ body.response.clientDataJSON_challenge}`);
  // In practice you embed the challenge lookup differently — see notes below

  const verification = await verifyAuthenticationResponse({
    response: body,
    expectedChallenge,
    expectedOrigin: "https://example.com",
    expectedRPID: "example.com",
    authenticator: {
      credentialID: credential.credentialId,
      credentialPublicKey: credential.publicKey,
      counter: credential.counter,
    },
    requireUserVerification: true,
  });

  if (!verification.verified) throw new Error("Authentication failed");

  // Update the counter to prevent replay
  await db.credentials.updateCounter(credential.id, verification.authenticationInfo.newCounter);

  return { userId: credential.userId, verified: true };
}

One thing SimpleWebAuthn’s documentation undersells: the challenge lookup on the server side needs care. The client doesn’t send the challenge back in plaintext — it’s embedded in clientDataJSON, which is base64url-encoded. The server parses it to find the challenge, then looks up the stored challenge to verify. Storing challenges keyed by a session token or by the challenge value itself both work; keyed by session is cleaner because it avoids challenge enumeration.


Attestation: What It Is and When You Need It

Attestation is the mechanism by which an authenticator proves what kind of device it is. During registration, the authenticator can include an attestation statement signed by the device manufacturer’s private key. The server can verify this against known manufacturer certificates to establish the provenance of the credential.

Three common attestation types:

Type What it means When to use
"none" No attestation — just the public key and credential Most consumer apps
"indirect" Authenticator can anonymise the certificate Balanced: device type known, no individual tracking
"direct" Full certificate chain from manufacturer High-assurance: know the exact authenticator model

For the vast majority of applications, use "none". Attestation adds complexity, breaks synced passkeys (iCloud/Google can’t provide manufacturer attestation for a credential created on a phone), and provides minimal additional security for consumer login flows.

Use direct attestation only when:

  • You need to enforce that credentials come from hardware-backed authenticators (e.g. you’re building an enterprise identity provider for privileged access)
  • You need to enforce a specific authenticator model or FIDO MDS (metadata service) trust level
  • Your compliance framework explicitly requires it

Microsoft Entra ID’s enforced attestation policy blocks all synced passkeys — if you apply it, users cannot use iCloud or Google passkey sync, only hardware keys. That’s intentional for privileged admin accounts but inappropriate as a blanket policy.


Fallback Strategy

Passkey deployments fail at recovery, not at adoption. Design the fallback chain before writing any code.

A working fallback chain in priority order:

1. Passkey (conditional UI autofill)
      ↓ if not available / dismissed
2. Another passkey on a different device (QR code / cross-device auth)
      ↓ if no passkeys enrolled
3. Magic link to verified email
      ↓ if email not available / expired
4. TOTP (authenticator app)
      ↓ if TOTP not enrolled
5. SMS OTP (accept the security trade-off, log the event)
      ↓ for account recovery only
6. Identity verification (support-assisted recovery with ID check)

The critical rule: never make the fallback invisible. Users who cannot see a fallback path assume they’re locked out. The UI should show the passkey flow as the default, with a clearly labelled “Use a different sign-in method” link that reveals the fallback chain.

The metric to track is fallback rate — the percentage of authentication events that don’t complete via passkey. At launch, 40–60% fallback is normal. A well-tuned deployment reaches below 5% within 90 days. If your fallback rate stays above 15% long-term, your conditional UI or device coverage has a problem.

Cross-device authentication

If a user wants to authenticate on a locked-down enterprise desktop that has no passkeys enrolled, they can use a passkey from their phone via QR code. The browser shows a QR code that the phone scans, establishing a proximity-bound channel (Bluetooth), and the phone authenticates on behalf of the desktop session. This is built into the platform — you don’t implement it, you just don’t break it. The mistake teams make is disabling "platform" and "cross-platform" transports, which removes this option.

Leave allowedTransports absent or explicitly include "hybrid" (the CTAP2 transport for cross-device flows):

1
2
3
4
5
allowCredentials: credentials.map(c => ({
  id: c.credentialId,
  type: "public-key",
  transports: c.transports,  // preserve whatever transports were reported at registration
}))

UX Patterns That Actually Drive Adoption

The technology is ready. Deployments fail on UX.

Make enrollment the happy path, not a setting

Passkey enrollment prompted immediately after a successful password login sees 2x higher completion than enrollment buried in security settings. The moment of successful auth is when users are most willing to improve their security posture. Show it then.

"You're signed in. Set up a passkey for faster, more secure sign-ins next time?"
[Set up passkey]  [Not now]  [Don't ask again]

Don’t gate the session on enrollment. Never make it mandatory at first login.

Describe passkeys in terms of the device, not the standard

“Set up a passkey using your fingerprint / Face ID / Windows Hello” converts better than “Set up a passkey.” Users understand their device; they don’t understand FIDO2.

Name registered passkeys meaningfully

On devices with multiple passkeys registered, users need to distinguish them. Capture the authenticator name at registration:

1
2
3
4
5
6
// From the registration response
const authenticatorName = body.response.getTransports?.().includes("hybrid")
  ? "Phone (cross-device)"
  : navigator.userAgent.includes("iPhone")
  ? "iPhone"
  : navigator.platform ?? "Unknown device";

Store and display this on the credentials management page so users can delete specific passkeys without guessing which is which.

Handle new-device enrollment proactively

When a user signs in via a passkey from device A, and the session is later accessed from device B (no passkey), prompt enrollment on device B at that point. Don’t wait for the user to seek it out.


Enterprise Deployment Considerations

Enterprise passkey deployments encounter a specific set of problems that consumer deployments don’t.

Shared workstations: Kiosks, lab computers, and shared desks don’t have a single user’s biometrics enrolled. Use userVerification: "preferred" for these environments and lean on cross-device auth (phone as authenticator). Alternatively, issue hardware keys (YubiKey 5 series) for users who work across shared machines. See the YubiKey for SSH, GPG, sudo, and FIDO2 post for hardware key setup.

Managed device lock-in: Windows Hello for Business credentials are bound to the machine and don’t sync. Users who get a new laptop lose their passkeys. Design the recovery flow and communicate it proactively during device provisioning, not reactively when the user is locked out.

Attestation policy creep: Starting with "none" and then moving to "direct" later is a breaking change — you’ll need to re-enroll all credentials. If attestation is a compliance requirement, implement it from day one. If it’s not, skip it.

Legacy browser inventory: WebAuthn conditional UI requires Chrome 108+, Safari 16+, Firefox 119+. Check your browser distribution before mandating passkeys. The long tail of enterprise-managed IE/Edge Legacy browsers is the most common blocker.


Database Schema

A minimal credential store for a relying party:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
CREATE TABLE webauthn_credentials (
  id              UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  user_id         UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
  credential_id   BYTEA NOT NULL UNIQUE,
  public_key      BYTEA NOT NULL,
  counter         BIGINT NOT NULL DEFAULT 0,
  device_type     TEXT NOT NULL CHECK (device_type IN ('singleDevice', 'multiDevice')),
  transports      TEXT[],
  display_name    TEXT,          -- e.g. "iPhone 15 Pro"
  created_at      TIMESTAMPTZ NOT NULL DEFAULT now(),
  last_used_at    TIMESTAMPTZ,
  backed_up       BOOLEAN NOT NULL DEFAULT false
);

CREATE INDEX ON webauthn_credentials (user_id);
CREATE INDEX ON webauthn_credentials (credential_id);

backed_up comes from the authenticator’s flags (authenticatorData.flags.BE and BS bits in CTAP2 terminology). A credential with BE=1 (backup eligible) and BS=1 (backed up) is synced. This is how you detect synced vs. device-bound credentials if you care for policy purposes.


Testing

WebAuthn is notoriously awkward to test because it requires a real authenticator. Three practical approaches:

Virtual authenticators in Chrome DevTools. Under DevTools → Application → WebAuthn, you can create a virtual authenticator with various configurations (resident keys, user verification, transport). Fully controllable, no hardware required. Works in Puppeteer and Playwright via the CDP protocol.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
// Playwright example
const client = await page.context().newCDPSession(page);
await client.send("WebAuthn.enable");
const { authenticatorId } = await client.send("WebAuthn.addVirtualAuthenticator", {
  options: {
    protocol: "ctap2",
    transport: "internal",
    hasResidentKey: true,
    hasUserVerification: true,
    isUserVerified: true,
  },
});

Software authenticators. webauthn-emulator and similar projects give you a fully scriptable authenticator for CI. Not suitable for security testing (no real hardware guarantees), but fine for regression tests.

Real hardware. A YubiKey 5 NFC costs ~$50 and covers every transport except platform. Keep one in CI attached via USB-IP or a dedicated test machine. Test the actual hardware path at least weekly.


What Ships With Platforms in 2026

You don’t always need to build this yourself. The platform support as of mid-2026:

Platform / Provider Passkey support Conditional UI Cross-device (QR)
iOS 16+ / Safari Synced via iCloud Yes Yes
Android 9+ / Chrome Synced via Google Yes Yes
Windows 11 / Edge Windows Hello Yes Yes
macOS Ventura+ / Safari Synced via iCloud Yes Yes
Linux / Chrome No sync (hardware key only) Yes Yes
YubiKey 5 series Device-bound N/A Via CTAP2 QR
1Password Synced (cross-platform) Yes Partial

Linux remains the awkward case — there is no built-in platform passkey sync. Users on Linux need a hardware key or a cross-platform sync provider (1Password, Bitwarden) with browser extension. For developer-facing tools where Linux is common, this is a real consideration.


Passkeys are the best authentication primitive available — phishing-resistant by construction, passwordless, and increasingly frictionless on mobile. The implementation complexity is real but manageable with the right library (SimpleWebAuthn for Node, py_webauthn for Python, go-webauthn for Go). The deployment complexity is almost entirely in the fallback chain and the UX around enrollment and recovery. Get those right and you will ship something that is both more secure and more pleasant to use than a password form.

Comments