WebAuthn / Passkeys
WebAuthn (FIDO2) is the W3C standard for phishing-resistant authentication: hardware-bound keys, biometric verification, no shared secrets to phish. Passkeys are the consumer face — same primitives, synced across devices via the platform.
Registration, authentication, passkeys
EXAMPLE
// 1) The picture
// • User has an authenticator (Touch ID / Face ID / Windows Hello / YubiKey / Android FP)
// • Authenticator generates a unique key pair PER (origin, user)
// • PRIVATE KEY never leaves the authenticator
// • PUBLIC KEY shipped to your server during registration
// • Login: server sends a challenge, authenticator signs with private key, server verifies with public key
// • Phishing-resistant because the origin is part of what's signed
// 2) Install
// npm install @simplewebauthn/server @simplewebauthn/browser
import {
generateRegistrationOptions, verifyRegistrationResponse,
generateAuthenticationOptions, verifyAuthenticationResponse,
} from '@simplewebauthn/server';
import { startRegistration, startAuthentication } from '@simplewebauthn/browser';
// 3) Configuration
const rpName = 'My App';
const rpID = 'app.example.com'; // must match the origin's domain
const origin = 'https://app.example.com';
// 4) Registration — server side
app.post('/webauthn/register/options', async (req, res) => {
const user = req.session.user;
const existing = await db.passkey.findMany({ where: { userId: user.id } });
const options = await generateRegistrationOptions({
rpName,
rpID,
userID: Buffer.from(String(user.id)),
userName: user.email,
attestationType: 'none', // 'direct' for hardware attestation
excludeCredentials: existing.map((p) => ({
id: Buffer.from(p.credentialId, 'base64url'),
type: 'public-key',
transports: p.transports,
})),
authenticatorSelection: {
residentKey: 'preferred', // create discoverable credentials (passkeys)
userVerification: 'preferred',
authenticatorAttachment: undefined, // 'platform' or 'cross-platform' to filter
},
});
await redis.set(`webauthn:reg:${user.id}`, options.challenge, 'EX', 60);
res.json(options);
});
// 5) Registration — client side
async function register() {
const options = await fetch('/webauthn/register/options', { method: 'POST' }).then((r) => r.json());
const attResp = await startRegistration(options);
const result = await fetch('/webauthn/register/verify', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(attResp),
});
if (!result.ok) alert('Registration failed');
else alert('Passkey registered!');
}
// 6) Registration — verify
app.post('/webauthn/register/verify', async (req, res) => {
const user = req.session.user;
const expectedChallenge = await redis.get(`webauthn:reg:${user.id}`);
const verification = await verifyRegistrationResponse({
response: req.body,
expectedChallenge,
expectedOrigin: origin,
expectedRPID: rpID,
requireUserVerification: true,
});
if (!verification.verified) return res.sendStatus(403);
const info = verification.registrationInfo;
await db.passkey.create({
data: {
userId: user.id,
credentialId: Buffer.from(info.credentialID).toString('base64url'),
publicKey: Buffer.from(info.credentialPublicKey).toString('base64url'),
counter: info.counter,
transports: req.body.response.transports ?? [],
},
});
res.json({ ok: true });
});
// 7) Authentication — server options
app.post('/webauthn/login/options', async (req, res) => {
const user = await db.user.findUnique({ where: { email: req.body.email } });
if (!user) return res.sendStatus(404);
const passkeys = await db.passkey.findMany({ where: { userId: user.id } });
const options = await generateAuthenticationOptions({
rpID,
userVerification: 'preferred',
allowCredentials: passkeys.map((p) => ({
id: Buffer.from(p.credentialId, 'base64url'),
type: 'public-key',
transports: p.transports,
})),
});
await redis.set(`webauthn:auth:${user.id}`, options.challenge, 'EX', 60);
res.json(options);
});
// 8) Authentication — client
async function login(email) {
const options = await fetch('/webauthn/login/options', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email }),
}).then((r) => r.json());
const authResp = await startAuthentication(options);
const r = await fetch('/webauthn/login/verify', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(authResp),
});
if (r.ok) location.href = '/dashboard';
}
// 9) Authentication — verify
app.post('/webauthn/login/verify', async (req, res) => {
const passkey = await db.passkey.findUnique({ where: { credentialId: req.body.id } });
if (!passkey) return res.sendStatus(404);
const expectedChallenge = await redis.get(`webauthn:auth:${passkey.userId}`);
const v = await verifyAuthenticationResponse({
response: req.body,
expectedChallenge,
expectedOrigin: origin,
expectedRPID: rpID,
authenticator: {
credentialPublicKey: Buffer.from(passkey.publicKey, 'base64url'),
credentialID: Buffer.from(passkey.credentialId, 'base64url'),
counter: passkey.counter,
},
requireUserVerification: true,
});
if (!v.verified) return res.sendStatus(403);
await db.passkey.update({ where: { id: passkey.id }, data: { counter: v.authenticationInfo.newCounter } });
req.session.regenerate(() => {
req.session.userId = passkey.userId;
res.json({ ok: true });
});
});
// 10) Discoverable credentials (passkeys) — passwordless on the same device
// With residentKey: 'preferred' and userVerification: 'required', the authenticator stores the
// user identifier. The browser can find them automatically; the user picks an identity from the picker.
// Use allowCredentials: [] in generateAuthenticationOptions to enable usernameless flows.
// 11) Cross-device login (QR code, hybrid transport)
// • Phone authenticator with passkey can authenticate a desktop login via Bluetooth + QR
// • Built-in to Safari/Chrome/Edge; no special server code needed
// 12) Step-up authentication
// • Require 'userVerification: required' (PIN / biometric) for sensitive flows
// • Combine with a 'recent verification' timestamp
// 13) Recovery + multiple authenticators
// • Encourage users to register MULTIPLE keys (phone + hardware key)
// • Store backup codes for when all devices lost
// • Document recovery flow that doesn't fall back to weaker auth
// 14) Browser support
// • Chrome, Edge, Safari, Firefox, Brave — all modern stable versions
// • Mobile Safari + Chrome on Android
// • Hardware keys (YubiKey) via USB or NFC
// 15) UX patterns
// • 'Try passkey' button on login alongside password as a transition path
// • Show platform-specific copy ('Use Touch ID' on Mac)
// • Provide a 'Sign in on another device' option (cross-device flow)
// • After successful registration, immediately offer to add a backup key
// 16) Common bugs
// • rpID doesn't match origin → registrations succeed, logins silently fail across browsers
// • Origin scheme/host typo (http vs https) → verification fails
// • Counter not incremented → some authenticators reject as cloned key
// • Storing credentialPublicKey in wrong encoding (utf8 vs base64url) → verify fails
// • Forgot to require userVerification on sensitive endpoints
// • Trying to use WebAuthn over HTTP (not localhost) → browsers refuse
// • SimpleWebAuthn ASCII id required → use base64url encoding consistently
// • Multiple subdomains (rpID = example.com vs www.example.com) → choose the higher one carefully
// • Not regenerating session id after login → CSRF / session fixation risk
// • Browser deny chain — try / catch and show recovery flow
Why it matters
WebAuthn = hardware-bound key pairs per origin, no shared secrets to phish. Use @simplewebauthn/server + @simplewebauthn/browser for registration + authentication, store credential ids + public keys + counters per user, regenerate the session on login, and encourage multiple authenticators per account so a lost phone doesn’t lock people out forever. For consumers, default to passkeys (residentKey + userVerification).
Tip: Tweak the snippet with Try it Yourself », then sit the quiz at the bottom of the page.
Example
Example
// Passkeys: phishing-resistant, public-key, no shared secret. // Libs: @simplewebauthn/server, py_webauthn, webauthn-rails. // Prefer it for new apps; keep TOTP / SMS as fallback.Try it Yourself »
Discussion
Loading…