Implement WebAuthn Passkeys: Registration, Login, and Recovery
For developers adding passkeys to an existing web app. This shows the server and browser pieces for WebAuthn registration and authentication, the exact fields to persist, and a recovery flow that does not lock users out when they lose a device.
TL;DR — Add two server endpoints for WebAuthn registration and two for authentication, generate challenges server-side, verify attestation/assertion against your RP ID and origin, and store credential ID, public key, counter, transports, and backup state per user. The most common production failure is an RP ID/origin mismatch caused by testing on one host and verifying against another. Reading time: ~5 min
Goal
When you are done, users can create a passkey, sign in with it from your web app, and recover access with a separate recovery method if they lose all authenticators, while your server correctly verifies WebAuthn responses and persists the minimum credential state needed for future logins.
Prerequisites
- A web app served over HTTPS on your real domain; localhost is acceptable for local development
- Control of your application backend and database schema
- Node.js >= 20 — check with:
node --version
- A browser with WebAuthn/passkey support (current Chrome, Edge, Firefox, Safari)
- A database table for users and a new table for WebAuthn credentials
- Your relying party values at hand:
- RP ID, for example
example.com - Origin, for example
https://app.example.com
- RP ID, for example
- A recovery channel already available to the account owner: verified email, TOTP, or admin-assisted recovery
- Packages:
npm install @simplewebauthn/server @simplewebauthn/browser express
Steps
Step 1: Create the credential storage table
Use a table keyed by credential ID and user ID. Store the public key bytes, counter, transports, and backup flags.
CREATE TABLE webauthn_credentials (
id BIGSERIAL PRIMARY KEY,
user_id BIGINT 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,
backed_up BOOLEAN NOT NULL DEFAULT FALSE,
transports TEXT[] NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
last_used_at TIMESTAMPTZ NULL
);
CREATE INDEX webauthn_credentials_user_id_idx ON webauthn_credentials(user_id);
Success looks like: the database returns CREATE TABLE and CREATE INDEX with no duplicate-column errors.
Step 2: Add registration challenge and verification endpoints
Generate registration options on the server, store the challenge in the user session, then verify the attestation response.
import express from 'express';
import {
generateRegistrationOptions,
verifyRegistrationResponse,
generateAuthenticationOptions,
verifyAuthenticationResponse,
} from '@simplewebauthn/server';
const app = express();
app.use(express.json({ limit: '1mb' }));
const rpName = 'Example App';
const rpID = 'example.com';
const origin = 'https://app.example.com';
app.post('/webauthn/register/options', async (req, res) => {
const user = req.user;
const existing = await db.getUserCredentials(user.id);
const options = await generateRegistrationOptions({
rpName,
rpID,
userID: String(user.id),
userName: user.email,
userDisplayName: user.email,
timeout: 60000,
attestationType: 'none',
authenticatorSelection: {
residentKey: 'preferred',
userVerification: 'preferred',
authenticatorAttachment: 'platform',
},
excludeCredentials: existing.map(c => ({
id: c.credential_id,
transports: c.transports || undefined,
})),
supportedAlgorithmIDs: [-7, -257],
});
req.session.currentChallenge = options.challenge;
res.json(options);
});
app.post('/webauthn/register/verify', async (req, res) => {
const verification = await verifyRegistrationResponse({
response: req.body,
expectedChallenge: req.session.currentChallenge,
expectedOrigin: origin,
expectedRPID: rpID,
requireUserVerification: false,
});
if (!verification.verified || !verification.registrationInfo) {
return res.status(400).json({ error: 'registration_verification_failed' });
}
const { credential } = verification.registrationInfo;
await db.insertCredential({
user_id: req.user.id,
credential_id: Buffer.from(credential.id),
public_key: Buffer.from(credential.publicKey),
counter: credential.counter,
device_type: credential.deviceType,
backed_up: credential.backedUp,
transports: req.body.response?.transports || null,
});
req.session.currentChallenge = null;
res.json({ verified: true });
});
Success looks like: POST /webauthn/register/verify returns 200 with {"verified":true} after the browser prompt completes.
Step 3: Add the browser registration call
Call navigator.credentials.create() through a library wrapper and POST the result back unchanged.
<script type="module">
import { startRegistration } from 'https://cdn.jsdelivr.net/npm/@simplewebauthn/browser/+esm';
document.getElementById('create-passkey').addEventListener('click', async () => {
const options = await fetch('/webauthn/register/options', { method: 'POST' }).then(r => r.json());
const attResp = await startRegistration({ optionsJSON: options });
const verify = await fetch('/webauthn/register/verify', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(attResp),
}).then(r => r.json());
console.log(verify);
});
</script>
Success looks like: the browser shows a passkey prompt and the console prints {verified: true}.
Step 4: Add authentication challenge and verification endpoints
Look up allowed credentials for the user if they typed an identifier first; otherwise use discoverable credentials and identify the user from the assertion result.
app.post('/webauthn/auth/options', async (req, res) => {
const user = await db.findUserByEmail(req.body.email);
const creds = user ? await db.getUserCredentials(user.id) : [];
const options = await generateAuthenticationOptions({
rpID,
timeout: 60000,
userVerification: 'preferred',
allowCredentials: creds.map(c => ({
id: c.credential_id,
transports: c.transports || undefined,
})),
});
req.session.currentChallenge = options.challenge;
req.session.loginUserId = user?.id || null;
res.json(options);
});
app.post('/webauthn/auth/verify', async (req, res) => {
const credentialID = Buffer.from(req.body.id, 'base64url');
const dbCred = await db.getCredentialByCredentialId(credentialID);
if (!dbCred) return res.status(404).json({ error: 'credential_not_found' });
const verification = await verifyAuthenticationResponse({
response: req.body,
expectedChallenge: req.session.currentChallenge,
expectedOrigin: origin,
expectedRPID: rpID,
credential: {
id: dbCred.credential_id,
publicKey: dbCred.public_key,
counter: dbCred.counter,
transports: dbCred.transports || undefined,
},
requireUserVerification: false,
});
if (!verification.verified) {
return res.status(401).json({ error: 'authentication_verification_failed' });
}
await db.updateCredentialCounter(dbCred.id, verification.authenticationInfo.newCounter);
await db.touchCredential(dbCred.id);
req.session.userId = dbCred.user_id;
req.session.currentChallenge = null;
res.json({ verified: true });
});
Success looks like: POST /webauthn/auth/verify returns 200 with {"verified":true} and the session becomes authenticated.
Step 5: Add the browser authentication call
Use the options endpoint, call the browser API, and POST the assertion response back.
<script type="module">
import { startAuthentication } from 'https://cdn.jsdelivr.net/npm/@simplewebauthn/browser/+esm';
document.getElementById('sign-in-passkey').addEventListener('click', async () => {
const email = document.getElementById('email').value;
const options = await fetch('/webauthn/auth/options', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email }),
}).then(r => r.json());
const authResp = await startAuthentication({ optionsJSON: options });
const verify = await fetch('/webauthn/auth/verify', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(authResp),
}).then(r => r.json());
console.log(verify);
});
</script>
Success looks like: the browser prompts for biometric/PIN and the console prints {verified: true}.
Step 6: Add account recovery that does not depend on passkeys
Do not let passkeys be the only factor unless you accept permanent lockout. Add a recovery token flow and require step-up verification before deleting old credentials.
CREATE TABLE account_recovery_tokens (
id BIGSERIAL PRIMARY KEY,
user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
token_hash BYTEA NOT NULL UNIQUE,
expires_at TIMESTAMPTZ NOT NULL,
used_at TIMESTAMPTZ NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
import crypto from 'node:crypto';
app.post('/account/recovery/start', async (req, res) => {
const user = await db.findUserByEmail(req.body.email);
if (!user) return res.json({ ok: true });
const token = crypto.randomBytes(32).toString('base64url');
const tokenHash = crypto.createHash('sha256').update(token).digest();
await db.insertRecoveryToken(user.id, tokenHash, new Date(Date.now() + 15 * 60 * 1000));
await mailer.send(user.email, `https://app.example.com/account/recovery/finish?token=${token}`);
res.json({ ok: true });
});
Success looks like: the recovery start endpoint always returns {"ok":true} and the emailed link works once, then expires.
View as text
[Browser] ---- 1. POST /register/options ----> [App] [Browser] <---- 2. challenge/options -------- [App] [Browser] ---- 3. create() with authenticator -> [Passkey] [Browser] ---- 4. POST /register/verify -----> [App] [App] ---- 5. store pubkey/counter ----------> [DB]
Verify it works
Run these checks end to end.
curl -i -X POST https://app.example.com/webauthn/register/options \
-H 'Content-Type: application/json' \
-b 'session=YOUR_SESSION_COOKIE'
Expected shape: HTTP/1.1 200 OK and JSON containing challenge, rp, and user.
curl -i https://app.example.com/login
Expected shape on a healthy deployment:
HTTP/1.1 200 OK
Content-Type: text/html; charset=utf-8
A common bad reverse-proxy/origin setup looks like:
HTTP/1.1 302 Found
Location: http://app.example.com/login
That downgrade to http:// will break WebAuthn outside localhost.
Manual verification:
- Register a passkey on a test account.
- Sign out.
- Sign in with the passkey.
- Trigger recovery, use the emailed link, and confirm you can add a new passkey after step-up verification.
Common pitfalls
RP ID does not match the actual host
Mistake: setting rpID to app.example.com while users authenticate on example.com, or vice versa. Symptom: verification fails with errors shaped like Unexpected RP ID hash or browser SecurityError. Fix: set rpID to the registrable domain you actually use, for example example.com, and keep expectedOrigin exactly https://app.example.com.
Origin mismatch behind a proxy
Mistake: app thinks requests are http:// because X-Forwarded-Proto is ignored. Symptom: generated origin or redirects are wrong; curl -I shows Location: http://...; browser rejects WebAuthn on non-secure origin. Fix: trust your reverse proxy and force HTTPS at the edge and app layer.
Storing credential IDs in the wrong encoding
Mistake: storing base64url strings in one place and raw bytes in another without consistent conversion. Symptom: credential_not_found during login even though registration succeeded. Fix: store credential_id as bytes and convert request IDs with Buffer.from(id, 'base64url') before lookup.
Not updating the signature counter
Mistake: verifying assertions but never persisting newCounter. Symptom: cloned-authenticator detection never works, or later logins fail depending on your library policy. Fix: after successful authentication, write verification.authenticationInfo.newCounter back to the credential row.
Recovery flow lets attackers remove passkeys too easily
Mistake: email link alone can delete all authenticators immediately. Symptom: account takeover after mailbox compromise. Fix: require step-up verification after recovery before deleting existing passkeys; at minimum, re-verify email link plus recent session or admin approval for high-risk accounts.
This article was written by an AI system and published pending human review. Verify anything you intend to act on.
Have a project in mind?
Get an instant AI price estimate for it, or talk directly to our team.
One email a month on what we learn building with AI