Passkeys replace passwords with cryptographic credentials stored on the user’s device — TouchID, FaceID, or a hardware security key. The browser handles the biometric prompt; your server only stores a public key. There’s nothing to steal from your database, no password to phish, and the UX is a single touch. Passkeys use the WebAuthn API, which follows a registration ceremony (create credential) and authentication ceremony (prove possession) pattern. Claude Code writes the registration and login flows, credential storage schemas, and the progressive enhancement that keeps passwords as fallback.
CLAUDE.md for WebAuthn Projects
## Auth Stack
- WebAuthn: @simplewebauthn/server (Node) + @simplewebauthn/browser
- Storage: credentials table per user — one user can have multiple passkeys
- Challenge: random 32-byte value, stored in server session, single-use
- RP ID: your domain (e.g., "example.com") — must match origin
- Transport: "internal" for platform (TouchID/FaceID), "usb"/"nfc" for hardware keys
- Progressive enhancement: passkey UI shown if platform supports it; password always available
- attestation: "none" for consumer apps; "direct" for enterprise/FIDO certification
Database Schema
-- Each user can register multiple passkeys (phone, laptop, security key)
CREATE TABLE webauthn_credentials (
id TEXT PRIMARY KEY, -- base64url credentialID from authenticator
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
public_key BYTEA NOT NULL, -- COSE-encoded public key
counter BIGINT NOT NULL DEFAULT 0, -- replay attack prevention
device_type TEXT NOT NULL, -- 'singleDevice' or 'multiDevice'
backed_up BOOLEAN NOT NULL, -- synced to iCloud/Google Password Manager
transports TEXT[], -- ['internal', 'hybrid', 'usb']
aaguid TEXT, -- authenticator model identifier
friendly_name TEXT, -- user-provided name ("iPhone", "YubiKey")
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
last_used_at TIMESTAMPTZ
);
CREATE INDEX webauthn_credentials_user_id_idx ON webauthn_credentials(user_id);
-- Challenges: single-use, expire in 5 minutes
CREATE TABLE webauthn_challenges (
user_id UUID NOT NULL,
challenge TEXT NOT NULL, -- base64url encoded random bytes
type TEXT NOT NULL, -- 'registration' or 'authentication'
expires_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + INTERVAL '5 minutes',
PRIMARY KEY (user_id, type)
);
Registration Ceremony
// auth/webauthn/registration.ts
import {
generateRegistrationOptions,
verifyRegistrationResponse,
type VerifiedRegistrationResponse,
} from '@simplewebauthn/server';
import type { RegistrationResponseJSON } from '@simplewebauthn/types';
const RP_NAME = 'My App';
const RP_ID = 'example.com';
const ORIGIN = 'https://example.com';
// Step 1: Generate options and send to browser
export async function generateRegOptions(userId: string, userEmail: string) {
// Get existing credentials to prevent re-registering same device
const existingCredentials = await db.getCredentials(userId);
const options = await generateRegistrationOptions({
rpName: RP_NAME,
rpID: RP_ID,
userName: userEmail,
userID: Buffer.from(userId),
attestationType: 'none',
excludeCredentials: existingCredentials.map(cred => ({
id: cred.id,
transports: cred.transports,
})),
authenticatorSelection: {
residentKey: 'preferred', // Enables passkey (discoverable credential)
userVerification: 'preferred', // Require biometric/PIN
},
});
// Store challenge for verification (single-use)
await db.saveChallenge(userId, options.challenge, 'registration');
return options;
}
// Step 2: Verify browser response and store credential
export async function verifyRegResponse(
userId: string,
response: RegistrationResponseJSON,
): Promise<{ credentialId: string; deviceType: string }> {
const { challenge } = await db.getAndDeleteChallenge(userId, 'registration');
const verification = await verifyRegistrationResponse({
response,
expectedChallenge: challenge,
expectedOrigin: ORIGIN,
expectedRPID: RP_ID,
});
if (!verification.verified || !verification.registrationInfo) {
throw new Error('Registration verification failed');
}
const { credential, credentialDeviceType, credentialBackedUp } = verification.registrationInfo;
// Store credential for future authentication
await db.saveCredential({
id: Buffer.from(credential.id).toString('base64url'),
userId,
publicKey: Buffer.from(credential.publicKey),
counter: credential.counter,
deviceType: credentialDeviceType,
backedUp: credentialBackedUp,
transports: response.response.transports ?? [],
});
return { credentialId: credential.id.toString(), deviceType: credentialDeviceType };
}
Authentication Ceremony
// auth/webauthn/authentication.ts
import {
generateAuthenticationOptions,
verifyAuthenticationResponse,
} from '@simplewebauthn/server';
import type { AuthenticationResponseJSON } from '@simplewebauthn/types';
// Step 1: Generate options (can be usernameless with discoverable credentials)
export async function generateAuthOptions(userId?: string) {
const allowCredentials = userId
? (await db.getCredentials(userId)).map(cred => ({
id: cred.id,
transports: cred.transports,
}))
: []; // Empty = browser shows all available passkeys
const options = await generateAuthenticationOptions({
rpID: RP_ID,
allowCredentials,
userVerification: 'preferred',
});
// For usernameless: store challenge in session without user_id
if (userId) {
await db.saveChallenge(userId, options.challenge, 'authentication');
} else {
await session.set('authn_challenge', options.challenge);
}
return options;
}
// Step 2: Verify authentication response
export async function verifyAuthResponse(
response: AuthenticationResponseJSON,
userId?: string,
): Promise<string> {
const credentialId = response.id;
const credential = await db.getCredentialById(credentialId);
if (!credential) throw new Error('Credential not found');
const challenge = await db.getAndDeleteChallenge(credential.userId, 'authentication');
const verification = await verifyAuthenticationResponse({
response,
expectedChallenge: challenge,
expectedOrigin: ORIGIN,
expectedRPID: RP_ID,
credential: {
id: Buffer.from(credentialId, 'base64url'),
publicKey: credential.publicKey,
counter: credential.counter,
transports: credential.transports,
},
});
if (!verification.verified) throw new Error('Authentication failed');
// Update counter — prevents replay attacks
await db.updateCredentialCounter(credentialId, verification.authenticationInfo.newCounter);
await db.updateLastUsed(credentialId);
return credential.userId;
}
Browser-Side (JavaScript)
// client/passkeys.ts
import {
startRegistration,
startAuthentication,
browserSupportsWebAuthn,
} from '@simplewebauthn/browser';
export async function registerPasskey() {
if (!browserSupportsWebAuthn()) {
throw new Error('This browser does not support passkeys');
}
// Get options from server
const optionsRes = await fetch('/api/auth/passkeys/register/options', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
});
const options = await optionsRes.json();
// Trigger browser's passkey UI (FaceID, TouchID, etc.)
const registrationResponse = await startRegistration({ optionsJSON: options });
// Send response to server for verification
const verifyRes = await fetch('/api/auth/passkeys/register/verify', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(registrationResponse),
});
return verifyRes.json(); // { credentialId, deviceType }
}
export async function authenticateWithPasskey() {
const optionsRes = await fetch('/api/auth/passkeys/authenticate/options', {
method: 'POST',
});
const options = await optionsRes.json();
const authResponse = await startAuthentication({ optionsJSON: options });
const verifyRes = await fetch('/api/auth/passkeys/authenticate/verify', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(authResponse),
});
return verifyRes.json(); // { userId, sessionToken }
}
For the OAuth2/OIDC authentication that works alongside passkeys for federated identity, the OAuth2 auth guide covers the authorization code flow and token storage. For the JWT session management that follows successful passkey authentication, the authentication guide covers secure token handling. The Claude Skills 360 bundle includes WebAuthn skill sets covering registration/authentication ceremonies, credential storage, and progressive passkey enhancement. Start with the free tier to try passkey flow generation.