Contents

Passkeys and WebAuthn: Ship Passwordless Login With One Evening of Work

Contents

Passkeys swap passwords for a public/private keypair kept in the device keychain and unlocked by Face ID, Touch ID, or Windows Hello. The WebAuthn API does the crypto work, while @simplewebauthn/server version 13.3.0 covers Node, Bun, and Deno backends. Sign-up, autofill login, and account recovery all fit in one evening of work.

What Passkeys Actually Are and Why 2026 Is the Year to Ship Them

A passkey is a public/private keypair made on the user’s device. The private key never leaves the secure enclave (Secure Enclave on Apple hardware, StrongBox on Android, TPM on Windows). Only a signed challenge travels over the wire. Your server stores no shared secret to steal and no hash to crack offline. The signature is bound to your domain, so it can’t be phished. If a user visits examp1e.com instead of example.com, the browser refuses to sign. Credential phishing ends at the protocol layer.

The spec lists two flavors: discoverable (also called resident) credentials, and non-discoverable credentials. Discoverable passkeys store a username hint inside the authenticator. The browser then surfaces the account as an autofill chip without the user typing a thing. Non-discoverable credentials need the server to hand back an allowCredentials list first, which maps to the old U2F second-factor model. For a modern consumer login flow, you want discoverable credentials.

The sync story finally makes sense in 2026. iOS 18 and macOS 15 sync passkeys via iCloud Keychain across a user’s Apple devices. Android 9 and newer sync through Google Password Manager. Since Android 14, third-party vaults like 1Password, Bitwarden, Dashlane, and Proton Pass act as full passkey providers on mobile. Windows 11 23H2 supports synced passkeys through browser extensions, and 25H2 adds native third-party support. A user on a Pixel with 1Password can log in from a Mac running Safari without re-enrolling. The passkeys.dev support matrix tracks this better than any blog post, since support shifts monthly.

Diagram showing the evolution from FIDO U2F second-factor authentication to modern FIDO2 and WebAuthn passkeys
FIDO2 and WebAuthn evolved out of the earlier U2F second-factor standard, keeping the same hardware-backed keypair model but adding a browser API and discoverable credentials.
Image: Wikimedia Commons , CC BY-SA 4.0

Relying Party ID (rpID) is the setting you most need to get right on day one. It’s your registrable domain, example.com for most sites, and it scopes every credential you store. Change it later and every passkey you’ve issued goes invalid overnight. Pick the parent domain if you plan to share credentials across subdomains.

Why ship passkeys now rather than wait another year? Conditional UI has shipped in every major browser. The OS-level UX no longer needs the user to know the word “passkey.” The fallback story for older clients (device-bound hardware keys, or a magic-link email) is well understood. Product teams can now make passkeys the primary login method instead of hiding them behind an “advanced security” toggle.

Add Passkey Authentication with WebAuthn

The Registration Flow With a Server Challenge

Run the registration ceremony

Registration is the flow where a user makes a new passkey for your site. The server builds options that include a random challenge. The browser asks the authenticator to mint a keypair bound to your rpID. The server then stores the public key. Every field in PublicKeyCredentialCreationOptions has a purpose, but only a handful are load-bearing in practice.

The server starts by calling generateRegistrationOptions() from @simplewebauthn/server:

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

const options = await generateRegistrationOptions({
  rpName: 'Botmonster',
  rpID: 'example.com',
  userID: new TextEncoder().encode(user.id), // stable opaque handle, NOT email
  userName: user.email,
  userDisplayName: user.displayName,
  attestationType: 'none',
  authenticatorSelection: {
    residentKey: 'required',
    userVerification: 'preferred',
  },
  excludeCredentials: user.passkeys.map(p => ({
    id: p.credentialID,
    transports: p.transports,
  })),
});

session.challenge = options.challenge;
return options;

Two details do the real work here. residentKey: 'required' forces a discoverable credential so conditional UI can find it later. userVerification: 'preferred' asks for biometric or PIN checks when available, without hard-blocking older authenticators. The userID is a stable opaque handle that never changes for the life of the account. Don’t use the email address: users change emails, and you don’t want a dangling credential.

The browser receives that JSON and calls navigator.credentials.create({ publicKey: options }). The OS pops the native passkey sheet. The user taps Face ID or scans a fingerprint. The authenticator returns an attestationObject plus a clientDataJSON blob, and the client posts both back to your server. The server then verifies:

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

const verification = await verifyRegistrationResponse({
  response: req.body,
  expectedChallenge: session.challenge,
  expectedOrigin: 'https://example.com',
  expectedRPID: 'example.com',
});

if (verification.verified && verification.registrationInfo) {
  const { credentialID, credentialPublicKey, counter } = verification.registrationInfo;
  await db.passkeys.insert({
    userID: user.id,
    credentialID,
    publicKey: credentialPublicKey,
    counter,
    transports: req.body.response.transports ?? [],
  });
}

Attestation is usually overkill for consumer apps. Set attestationType: 'none' unless you’re building a regulated product that needs to prove which authenticator model the user holds. The FIDO Metadata Service path adds work most apps will never need.

The excludeCredentials list stops the user from double-registering the same authenticator on the same account. When the browser spots an ID it already holds, it grays out re-enrollment and prompts for a different device. Surface the common errors cleanly. NotAllowedError means the user cancelled. InvalidStateError usually means the authenticator is already registered. Clients with no authenticator should fall back to a magic-link email.

Conditional UI and the Autofill Experience

Enable conditional UI autofill

Login is the step that makes passkeys feel worth shipping. Conditional UI (also called autofill UI in the spec) surfaces the passkey inside the native browser autofill dropdown. The user never sees a separate “sign in with passkey” button. They click the username field, they see “Sign in as alice@example.com ”, they tap Face ID, they’re in. Total user effort: one tap.

There are two modes to keep straight. Modal mode is what you get when the user clicks a button and you call navigator.credentials.get() with no extra flags: the OS sheet pops right up. Conditional mode is what you call on page load with mediation: 'conditional'. The browser then waits, and surfaces the passkey as an autofill chip the moment the user focuses the username input.

The one HTML attribute that unlocks conditional UI is the webauthn token in the autocomplete list:

<input
  type="text"
  name="username"
  autocomplete="username webauthn"
  required
/>

Without webauthn in that list, the browser won’t offer passkeys in the autofill dropdown, full stop. The autocomplete attribute also carries WCAG weight, so build the sign-in field with accessible web form validation and ARIA in mind from the start. Feature-detect before you call conditional mode, or you’ll get an unhandled rejection on older Safari and Firefox builds:

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

if (
  PublicKeyCredential.isConditionalMediationAvailable &&
  await PublicKeyCredential.isConditionalMediationAvailable()
) {
  const options = await fetch('/webauthn/login/options').then(r => r.json());
  const assertion = await startAuthentication({ optionsJSON: options, useBrowserAutofill: true });
  const result = await fetch('/webauthn/login/verify', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(assertion),
  });
  if (result.ok) location.href = '/dashboard';
}

On the server, pass an empty allowCredentials: [] to generateAuthenticationOptions() for a discoverable-credential flow. The browser scans the keychain for a passkey that matches your rpID and hands back the one the user picks. That’s what makes username-less login work.

Chrome passkey autofill dropdown showing a stored passkey suggestion under the username field, above a biometric prompt
Conditional UI surfaces the passkey inside the browser's native autofill suggestion list, so one tap on the field plus Face ID is the entire login.
Image: Yubico Developer Docs

Update the stored counter after every good verify. Some authenticators bump it on every signature as a clone-detection signal. Ignore a counter regression and you’ll miss a cloned credential. Bump a lastUsedAt timestamp at the same time, so your security-settings page can show users when each passkey was last seen.

One JavaScript gotcha: keep the AbortController from the conditional call around. If the user clicks an explicit “sign in with passkey” button, call abort() on the conditional request before you start a modal one. Otherwise the two flows collide and both fail in ways that look like browser bugs.

A Working Hono Endpoint Pair

Wire the four server endpoints

Here is a minimal Hono app with the four endpoints you need. The shape is the same in Express, Fastify, or Elysia if those are more familiar.

import { Hono } from 'hono';
import { getSignedCookie, setSignedCookie } from 'hono/cookie';
import {
  generateRegistrationOptions,
  verifyRegistrationResponse,
  generateAuthenticationOptions,
  verifyAuthenticationResponse,
} from '@simplewebauthn/server';

const app = new Hono();
const rpID = process.env.RP_ID ?? 'localhost';
const origin = process.env.ORIGIN ?? 'http://localhost:3000';

app.post('/webauthn/register/options', async (c) => {
  const user = await getOrCreateUser(c);
  const options = await generateRegistrationOptions({
    rpName: 'Botmonster',
    rpID,
    userID: new TextEncoder().encode(user.id),
    userName: user.email,
    excludeCredentials: user.passkeys.map(p => ({ id: p.credentialID })),
    authenticatorSelection: { residentKey: 'required', userVerification: 'preferred' },
    attestationType: 'none',
  });
  await setSignedCookie(c, 'challenge', options.challenge, SECRET, { httpOnly: true, maxAge: 300 });
  return c.json(options);
});

app.post('/webauthn/register/verify', async (c) => {
  const user = await getUser(c);
  const expectedChallenge = await getSignedCookie(c, SECRET, 'challenge');
  const verification = await verifyRegistrationResponse({
    response: await c.req.json(),
    expectedChallenge: expectedChallenge as string,
    expectedOrigin: origin,
    expectedRPID: rpID,
  });
  if (verification.verified && verification.registrationInfo) {
    await savePasskey(user.id, verification.registrationInfo);
  }
  return c.json({ verified: verification.verified });
});

app.post('/webauthn/login/options', async (c) => {
  const options = await generateAuthenticationOptions({
    rpID,
    userVerification: 'preferred',
    allowCredentials: [],
  });
  await setSignedCookie(c, 'challenge', options.challenge, SECRET, { httpOnly: true, maxAge: 300 });
  return c.json(options);
});

app.post('/webauthn/login/verify', async (c) => {
  const body = await c.req.json();
  const passkey = await findPasskeyByCredentialID(body.id);
  const expectedChallenge = await getSignedCookie(c, SECRET, 'challenge');
  const verification = await verifyAuthenticationResponse({
    response: body,
    expectedChallenge: expectedChallenge as string,
    expectedOrigin: origin,
    expectedRPID: rpID,
    authenticator: {
      credentialID: passkey.credentialID,
      credentialPublicKey: passkey.publicKey,
      counter: passkey.counter,
      transports: passkey.transports,
    },
  });
  if (verification.verified) {
    await updateCounter(passkey.id, verification.authenticationInfo.newCounter);
    await startSession(c, passkey.userID);
  }
  return c.json({ verified: verification.verified });
});

A Postgres schema that backs it is small:

ColumnTypeNotes
credential_idbytea PRIMARY KEYbase64url on the wire, bytes in storage
user_iduuid NOT NULL REFERENCES users(id)foreign key to your users table
public_keybytea NOT NULLCOSE-encoded public key
counterbigint NOT NULL DEFAULT 0bumped on every signature
transportstext[]usb, nfc, ble, internal, hybrid
device_typetextsingleDevice or multiDevice
backed_upbooleantrue if synced to a cloud keychain
labeltextuser-editable (“iPhone 16”, “YubiKey 5C”)
last_used_attimestamptzfor security settings display
created_attimestamptz NOT NULL DEFAULT now()audit

Add an index on credential_id because verifyAuthenticationResponse looks up by it on every login.

A few origin and rpID traps burn almost every first-time builder. In local dev, your origin is http://localhost:3000 and your rpID is localhost with no port. In prod, the origin is https://example.com and the rpID is example.com. Subdomains share credentials when the rpID is set to the registrable parent. If you need cross-origin sharing for consent-flow pages, look at the newer Related Origin Requests feature rather than juggling multiple rpIDs.

If your backend is Go, the go-webauthn/webauthn package has the same four-function shape: BeginRegistration, FinishRegistration, BeginLogin, FinishLogin. The rest of the design maps 1:1. Python devs have py_webauthn from the Duo Labs team, and Java shops can use webauthn4j .

LibraryLanguageLicenseNotable Consumers
@simplewebauthn/serverTypeScript (Node, Bun, Deno)MITSupabase Auth, Clerk add-on, many indie SaaS
go-webauthn/webauthnGoBSD-2-ClauseAuthelia, Gitea, pocket-id
py_webauthnPythonBSD-3-ClauseDjango Allauth, FastAPI starters
webauthn4jJava/KotlinApache 2.0Keycloak, Spring Security

Device Loss, Account Recovery, and Multiple Passkeys Per User

Plan device-loss recovery

The hard part of shipping passkeys has very little to do with crypto. It’s the account-recovery story. Users lose phones, switch from iOS to Android, let password-manager subs lapse, or want to pair a hardware key with a synced passkey. The policies and UI patterns below are what keep passkey auth survivable in prod.

Always allow many passkeys per user. A security settings page should list each credential with its added-on date, last-used timestamp, transports, and a user-editable label. “iPhone 16” is a better label than a base64 credential ID. Right after the first passkey is set up, prompt the user to add a backup on a different device or provider. A second credential on a second sync fabric is the best anti-lockout step you can take.

Keycloak security settings page listing registered WebAuthn security keys with created date, label, and transport columns
Keycloak's account console is a good reference for what a per-user passkey management screen should expose: label, added-on date, AAGUID, and a delete action.
Image: Wikimedia Commons , Apache 2.0

The backedUp flag returned at sign-up tells you if the credential will survive device loss. A value of true means the credential is synced to iCloud, Google Password Manager, 1Password, or similar, and a new iPhone will inherit it. A value of false means the credential is device-bound, usually a hardware key. It must be paired with a backup, or the user is one lost YubiKey away from a support ticket.

Cross-device login, also called hybrid transport and once known as caBLE, lets a desktop user scan a QR code with their phone and log in using a passkey kept in the phone’s keychain. The browser and OS handle it for you. You don’t build anything, but you also shouldn’t block transports: ['hybrid'] in any allowCredentials list, since that breaks the mode.

Rank your recovery fallbacks with care. A second passkey on another device is the best option. An emailed magic link to a verified address is a fair middle ground. A recovery code printed at sign-up is fine if users will actually print it. Support-team-led identity checks are the last resort. Falling back to a plain password throws away the phishing resistance that made you adopt passkeys in the first place, so don’t do it. If you’re adding passkeys to an app that already relies on OAuth 2.0 login flows, keep both paths live during the rollout so users can cross-enroll without losing access.

Let users delete single passkeys, but refuse to delete the last credential unless a backup recovery method is in place. Otherwise you’ll spawn support tickets forever. When you move an existing password-plus-TOTP product, keep the old flow alive for 60 to 90 days. Offer passkey sign-up on every login. Retire the password field only after a user has at least one cloud-synced passkey on file.

Observability earns its keep the first time a user reports “it just says error.” Log every sign-up, login try, and verify failure with the reason string: challenge mismatch, origin mismatch, counter regression, unknown credential ID. The newer Signal API (PublicKeyCredential.signalAllAcceptedCredentials and PublicKeyCredential.signalCurrentUserDetails) lets you push server-side state changes back to the authenticator. Stale entries get pruned from the passkey list without user input. Feature-detect it via PublicKeyCredential.getClientCapabilities() and call it on every good login.

Troubleshooting the Four Errors You Will Actually Hit

Four failure modes cover most of what you’ll see in logs. SecurityError: The operation is insecure usually means the page loaded over plain HTTP on a host that isn’t localhost, or the set rpID doesn’t match the current origin. WebAuthn needs a secure context. HTTPS in production and localhost in dev are the only accepted cases. Use https://localhost with a self-signed cert if you need to mirror prod more closely.

An rpID mismatch between sign-up and login is the most common reason a flow works in staging and fails in prod. A credential made against rpID: 'staging.example.com' is useless at example.com. Pick the registrable parent domain and keep it stable.

127.0.0.1 isn’t treated the same way as localhost across browsers. Stick with localhost in dev, or use an ngrok tunnel when you need a public HTTPS hostname to test cross-device hybrid flows.

NotAllowedError is the catch-all for user cancel, timeout, and browser policy denial. You can’t tell these cases apart from JavaScript by design. The spec hides the reason so sites can’t probe authenticator state. Show a generic “that didn’t work, try again or use a magic link” message rather than guess.

Ship the endpoints. Wire up the autofill attribute. Prompt for a second passkey right after the first. Your login form will feel instant for the 90% of users on modern hardware, and it still degrades cleanly for everyone else. It really is an evening of work.