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
- Upgrade path: users sign in normally, then you offer “Create a passkey”
- Passkey-first sign-in: autofill or “Sign in with a passkey” button
- 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.comif that is the scope you intend
What goes wrong:
- you register on
staging.example.combut verify againstexample.com - you move from
www.example.comtoapp.example.comand 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'oruserVerification: '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.