caduh

Passkeys / WebAuthn in Practice

12 min read

A practical guide to shipping passkeys with WebAuthn: registration, sign-in, recovery, account settings, and the RP ID/origin gotchas that usually break rollouts.

Passkeys / WebAuthn in Practice

Registration, login, recovery, and fallback flows that work in the real world

Goal: help you ship passkeys without confusing your users, locking them out, or getting stuck on RP ID, origin, or browser UX edge cases.


TL;DR

  • Passkeys are WebAuthn credentials. They are not a separate protocol.
  • Use passkeys when you want phishing-resistant sign-in and less password pain.
  • For most product teams, the safest rollout is: keep your existing login, add “Create a passkey” after sign-up or from account settings, then add passkey sign-in/autofill.
  • A passkey rollout lives or dies on four things: RP ID/origin correctness, challenge handling, recovery, and good UX copy around OS dialogs.
  • In your backend, save the credential ID, public key, counter, transports, and useful metadata like device type and backed up state.
  • After successful WebAuthn authentication, create your normal app session. Passkeys replace passwords, not sessions.

1) What Passkeys Actually Are

A passkey is a public/private key pair used through WebAuthn. The private key stays in an authenticator (device, security key, or credential manager). Your server stores the public key and verifies signatures on server-generated challenges.

That gives you the security properties people actually want:

  • no shared secret stored like a password
  • phishing resistance tied to the requesting site
  • better protection when your DB leaks
  • a much better sign-in experience on devices people already trust

The practical mental model

  • WebAuthn = the web standard and browser API
  • Passkey = the user-facing label for a WebAuthn credential, often synced across devices
  • Authenticator = the thing holding the private key: Face ID/Touch ID, Android device, Windows Hello, security key, password manager, etc.

If you are building for the web, you are really implementing WebAuthn and offering it to users as passkeys.


2) When Passkeys Are a Great Fit

Best uses

  • First-party web apps that want faster sign-in and fewer password resets
  • Consumer apps where credential stuffing and phishing are painful
  • Admin portals where stronger auth matters
  • Step-up auth for sensitive actions like payouts, email changes, or API key creation

Good rollout patterns

  1. Upgrade path: users sign in normally, then you offer “Create a passkey”
  2. Passkey-first sign-in: autofill or “Sign in with a passkey” button
  3. Passwordless account creation: useful, but only after you have a recovery story

When not to lead with “passwordless everything”

  • your recovery flow is weak
  • you support many shared or kiosk devices
  • your identity proofing is strict and not yet passkey-friendly
  • your team cannot confidently support account recovery without falling back to insecure shortcuts

3) The Three Product Decisions That Matter Most

3.1 Are passkeys primary, optional, or just a backup?

For most teams, start with optional but strongly encouraged.

Good first release:

  • keep password or magic-link login
  • let signed-in users add a passkey
  • offer passkey sign-in on next visit
  • show passkey management in account settings

3.2 Username-first or account-selector-first?

You have two main sign-in patterns:

  • Username-first: user enters email/username, then you ask for a passkey tied to that account
  • Passkey-first: you let the browser show discoverable credentials and the authenticator tells you which account the user selected

Username-first is simpler to reason about. Passkey-first feels smoother once your rollout matures.

3.3 What is your recovery path?

Before you push passkeys hard, decide how a locked-out user gets back in.

Common options:

  • a second passkey on another device
  • a hardware security key as backup
  • email-based recovery for lower-risk consumer apps
  • support-assisted recovery with stronger identity checks for higher-risk systems

Do not force users to delete their password until recovery is genuinely solid.


4) How the Flows Work

4.1 Registration (create a passkey)

Browser -> Your server: start registration
Your server -> Browser: challenge + RP options
Browser -> Authenticator: create credential
Authenticator -> Browser: attestation response
Browser -> Your server: verify registration response
Your server: save credential and mark passkey enabled

What your server must do:

  • generate a random challenge
  • bind it to the current user and short expiry
  • send correct RP ID and user data
  • verify the signed response
  • store the resulting credential details

4.2 Authentication (sign in with a passkey)

Browser -> Your server: start authentication
Your server -> Browser: challenge + allowed credentials or discoverable mode
Browser -> Authenticator: sign challenge
Authenticator -> Browser: assertion response
Browser -> Your server: verify authentication response
Your server: create normal app session / cookie

Important: passkeys do not replace sessions

After verification succeeds, issue your normal session cookie or server session. Do not try to turn WebAuthn assertions into your whole session model.


5) The Settings That Usually Matter

5.1 residentKey / discoverable credentials

If you want users to sign in without typing their username first, you want discoverable credentials.

Good default for passkey-heavy consumer apps:

  • registration: residentKey: 'required' or at least 'preferred'

That gives the authenticator enough information to offer account selection later.

5.2 userVerification

Good default:

  • userVerification: 'preferred'

This keeps UX smoother while still using local verification when available. For stricter environments, use:

  • userVerification: 'required'

5.3 attestation

Good default:

  • attestation: 'none'

Most apps do not need device attestation. Treat attestation as an advanced feature for controlled fleets, device policy, or enterprise environments.

5.4 transports

Save the reported transports when you register a credential. They help browsers present better cross-device and security-key UX later.


6) The Gotchas That Break Real Rollouts

6.1 RP ID vs origin

This is the most common production footgun.

  • Origin is the full web origin used in the browser ceremony
  • RP ID is the relying-party domain scope for the credential

Examples:

  • app at https://app.example.com
  • RP ID could be example.com if that is the scope you intend

What goes wrong:

  • you register on staging.example.com but verify against example.com
  • you move from www.example.com to app.example.com and forget the RP setup
  • you test in one subdomain and ship with another

If your frontend and backend disagree about origin or RP ID, verification fails.

6.2 Challenge handling

Your challenges must be:

  • random
  • short-lived
  • single-use
  • bound to the intended flow and user/session

Do not:

  • reuse challenges
  • leave them valid for too long
  • accept a response without matching the saved challenge

6.3 Duplicate registration

Without excludeCredentials, users can re-register the same authenticator and create messy UX.

6.4 Recovery after device loss

Your product is not “done” because the happy path works on your phone.

Plan for:

  • lost phone
  • deleted credential manager
  • new laptop
  • user changed email
  • user used passkeys once and forgot what they did

6.5 Wrong launch sequence

Many teams put “Create a passkey” on the raw sign-in page first. That often performs worse than prompting during:

  • account creation
  • account recovery
  • account settings
  • right after a successful sign-in

7) Minimal Backend Model

A passkey table can be very small.

CREATE TABLE passkeys (
  id TEXT PRIMARY KEY,                 -- credential ID
  user_id UUID NOT NULL,
  webauthn_user_id BYTEA NOT NULL,     -- stable WebAuthn user handle
  public_key BYTEA NOT NULL,
  counter BIGINT NOT NULL DEFAULT 0,
  transports TEXT[],
  device_type TEXT,                    -- singleDevice / multiDevice if available
  backed_up BOOLEAN,
  created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
  last_used_at TIMESTAMPTZ
);

CREATE INDEX idx_passkeys_user_id ON passkeys(user_id);

Also store short-lived challenge state, either in:

  • server session
  • Redis
  • a DB table with TTL/expiry

8) Copy-Paste Example (Node/TypeScript)

This example uses SimpleWebAuthn because it keeps the article practical instead of forcing everyone to hand-roll CBOR and crypto details.

8.1 Start registration

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

app.post('/webauthn/register/options', async (req, res) => {
  const user = await requireSignedInUser(req);
  const userPasskeys = await db.getUserPasskeys(user.id);

  const options = await generateRegistrationOptions({
    rpName: 'Caduh',
    rpID: 'caduh.com',
    userName: user.email,
    userID: user.webauthnUserId, // stable random bytes/id, not raw email
    attestationType: 'none',
    excludeCredentials: userPasskeys.map((p) => ({
      id: p.id,
      transports: p.transports ?? [],
    })),
    authenticatorSelection: {
      residentKey: 'required',
      userVerification: 'preferred',
    },
  });

  await db.saveChallenge({
    userId: user.id,
    flow: 'registration',
    challenge: options.challenge,
    expiresAt: new Date(Date.now() + 5 * 60_000),
  });

  res.json(options);
});

8.2 Verify registration

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

app.post('/webauthn/register/verify', async (req, res) => {
  const user = await requireSignedInUser(req);
  const expected = await db.getLatestChallenge(user.id, 'registration');

  const verification = await verifyRegistrationResponse({
    response: req.body,
    expectedChallenge: expected.challenge,
    expectedOrigin: 'https://app.caduh.com',
    expectedRPID: 'caduh.com',
    requireUserVerification: false,
  });

  if (!verification.verified || !verification.registrationInfo) {
    return res.status(400).json({ verified: false });
  }

  const { credential, credentialDeviceType, credentialBackedUp } = verification.registrationInfo;

  await db.insertPasskey({
    id: credential.id,
    userId: user.id,
    webauthnUserId: user.webauthnUserId,
    publicKey: credential.publicKey,
    counter: credential.counter,
    transports: credential.transports ?? [],
    deviceType: credentialDeviceType,
    backedUp: credentialBackedUp,
  });

  await db.consumeChallenge(expected.id);
  res.json({ verified: true });
});

8.3 Start authentication

Username-first

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

app.post('/webauthn/login/options', async (req, res) => {
  const user = await db.getUserByEmail(req.body.email);
  if (!user) return res.status(404).json({ error: 'Unknown account' });

  const passkeys = await db.getUserPasskeys(user.id);

  const options = await generateAuthenticationOptions({
    rpID: 'caduh.com',
    userVerification: 'preferred',
    allowCredentials: passkeys.map((p) => ({
      id: p.id,
      transports: p.transports ?? [],
    })),
  });

  await db.saveChallenge({
    userId: user.id,
    flow: 'authentication',
    challenge: options.challenge,
    expiresAt: new Date(Date.now() + 5 * 60_000),
  });

  res.json(options);
});

Passkey-first / account-selector mode

app.post('/webauthn/login/options/passkey-first', async (req, res) => {
  const options = await generateAuthenticationOptions({
    rpID: 'caduh.com',
    userVerification: 'preferred',
    allowCredentials: [],
  });

  await db.saveAnonymousChallenge({
    flow: 'authentication',
    challenge: options.challenge,
    expiresAt: new Date(Date.now() + 5 * 60_000),
  });

  res.json(options);
});

8.4 Verify authentication

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

app.post('/webauthn/login/verify', async (req, res) => {
  const credentialId = req.body.id;
  const passkey = await db.getPasskeyById(credentialId);
  if (!passkey) return res.status(400).json({ verified: false });

  const expected = await db.getLatestChallenge(passkey.userId, 'authentication');

  const verification = await verifyAuthenticationResponse({
    response: req.body,
    expectedChallenge: expected.challenge,
    expectedOrigin: 'https://app.caduh.com',
    expectedRPID: 'caduh.com',
    credential: {
      id: passkey.id,
      publicKey: passkey.publicKey,
      counter: passkey.counter,
      transports: passkey.transports ?? [],
    },
    requireUserVerification: false,
  });

  if (!verification.verified) {
    return res.status(400).json({ verified: false });
  }

  await db.updatePasskeyCounter(passkey.id, verification.authenticationInfo.newCounter);
  await db.consumeChallenge(expected.id);

  const session = await createAppSession(passkey.userId);
  setSessionCookie(res, session);

  res.json({ verified: true });
});

9) Browser Examples

9.1 Register a passkey

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

async function createPasskey() {
  const options = await fetch('/webauthn/register/options', { method: 'POST' }).then(r => r.json());
  const registration = await startRegistration({ optionsJSON: options });

  const result = await fetch('/webauthn/register/verify', {
    method: 'POST',
    headers: { 'content-type': 'application/json' },
    body: JSON.stringify(registration),
  }).then(r => r.json());

  return result;
}

9.2 Sign in with a passkey button

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

async function signInWithPasskey() {
  const options = await fetch('/webauthn/login/options/passkey-first', {
    method: 'POST',
  }).then(r => r.json());

  const assertion = await startAuthentication({ optionsJSON: options });

  const result = await fetch('/webauthn/login/verify', {
    method: 'POST',
    headers: { 'content-type': 'application/json' },
    body: JSON.stringify(assertion),
  }).then(r => r.json());

  return result;
}

9.3 Conditional UI / autofill on your login form

<input
  type="text"
  name="username"
  autocomplete="username webauthn"
  autofocus
/>
const supported =
  window.PublicKeyCredential &&
  PublicKeyCredential.isConditionalMediationAvailable &&
  await PublicKeyCredential.isConditionalMediationAvailable();

if (supported) {
  const options = await fetch('/webauthn/login/options/passkey-first', {
    method: 'POST',
  }).then(r => r.json());

  const credential = await navigator.credentials.get({
    publicKey: options,
    mediation: 'conditional',
  });

  if (credential) {
    await fetch('/webauthn/login/verify', {
      method: 'POST',
      headers: { 'content-type': 'application/json' },
      body: JSON.stringify(credential),
    });
  }
}

This lets passkeys appear naturally in the username field while still allowing passwords or magic links as fallback.


10) Recovery and Fallback That Won’t Haunt You Later

Your recovery plan should exist before you encourage users to rely on passkeys.

Good consumer defaults

  • let users register more than one passkey
  • keep a secondary sign-in path during rollout
  • show passkey status clearly in account settings
  • provide a recovery path that matches your risk level

For lower-risk consumer apps

A practical rollout often looks like:

  • passkey + password during migration
  • passkey + email recovery link
  • optional second passkey in settings

For higher-risk apps

Avoid relying on weak recovery methods. Consider:

  • support-assisted recovery
  • stronger identity proofing
  • backup hardware security key
  • recovery codes or previously verified devices

The best nudge

After a successful login, say:

  • “Create a passkey for faster, more secure sign-in on this device.”

That timing tends to work better than interrupting the user on the first page load.


11) Account Settings UX That Keeps Support Tickets Down

A good passkey settings page should let users:

  • create a new passkey
  • see that passkeys are enabled
  • rename passkeys (“MacBook Pro”, “iPhone”, “YubiKey”)
  • revoke a lost device
  • understand what sign-in methods still work if they disable passkeys

Useful metadata to surface:

  • device label chosen by user
  • last used date
  • whether it is a synced or single-device credential, if you capture that distinction

Do not hide passkeys behind obscure security submenus. They should sit alongside password, email, and MFA settings.


12) Common Mistakes

Mistake 1: Thinking passkeys are just a frontend feature

Most failures happen in backend verification, challenge storage, session creation, or recovery.

Mistake 2: Hard-coding the wrong RP ID

This breaks staging, subdomains, migrations, and local testing.

Mistake 3: Skipping excludeCredentials

Users end up re-registering the same device and your settings page becomes nonsense.

Mistake 4: Treating WebAuthn as a full auth stack

Passkeys solve credential proof. You still need sessions, logout, rate limits, abuse handling, and account recovery.

Mistake 5: Forcing users to pick “password or passkey?”

That UI often adds confusion. Autofill or a passkey button plus a normal form works better.

Mistake 6: Killing passwords too early

Only retire passwords after users have:

  • adopted passkeys
  • added backup methods
  • and your support team knows how recovery works

Mistake 7: Ignoring support and content design

Users are often interacting with an OS dialog they do not fully understand. Your app should explain:

  • what is happening
  • why it is safe
  • what to do next if they cancel

13) What “Good” Looks Like

  • The happy path works on desktop, mobile, and at least one cross-device scenario
  • Users can add a passkey from account settings in under a minute
  • Sign-in works from a button and, where supported, from autofill/conditional UI
  • The backend stores all needed credential data and updates counters correctly
  • There is a clear path for lost device recovery
  • Your support team can explain passkeys in one paragraph without escalating every ticket

14) 2 AM Runbook

Error: NotAllowedError

Usually means one of:

  • user cancelled
  • timeout expired
  • browser/authenticator could not satisfy your requested options
  • residentKey: 'required' or userVerification: 'required' was too strict for the current authenticator

Error: verification failed because of origin or RP mismatch

Check:

  • exact frontend origin
  • expected origin on server
  • RP ID used at registration
  • RP ID used at authentication
  • staging vs production hostnames

Error: no credential found during verify

Likely causes:

  • you did passkey-first auth but did not map returned credential ID to a user
  • DB lookup bug
  • credential was deleted or never persisted

Error: counter regression or verification weirdness

Check whether:

  • you actually stored and update the counter
  • you mixed environments
  • the user re-registered and your DB is pointing at old credential metadata

Users say “my passkey disappeared”

Check:

  • whether they changed devices
  • whether the credential manager synced it
  • whether they signed into the same account/email
  • whether they still have another registered passkey or fallback method

Support reply template

“Passkeys are secure sign-ins stored on your device or credential manager. If you no longer have access to that device, use your backup sign-in method or recovery option. Once signed in, you can add a new passkey from Account Settings.”


15) Recommended Rollout Plan

Phase 1: Add passkey creation for signed-in users

  • account settings
  • post-login prompt
  • keep existing login methods

Phase 2: Add passkey sign-in

  • “Sign in with a passkey” button
  • username-first or passkey-first flow
  • support conditional UI where available

Phase 3: Improve recovery and management

  • multiple passkeys
  • device labels
  • revoke lost devices
  • clearer settings UX

Phase 4: Consider reducing password dependence

Only after adoption, recovery quality, and support readiness are all strong.


16) Bottom Line

Passkeys are one of the few auth upgrades that improve both security and UX.

But the implementation is not just “call WebAuthn once.” The real work is:

  • correct RP ID/origin design
  • robust challenge handling
  • sane account recovery
  • good account settings
  • and a rollout that respects how normal people actually sign in

Get those right, and passkeys stop feeling like a demo feature and start feeling like the obvious default.