WebAuthn and Passkeys
Public-key credentials bound to your origin — the first widely deployed login that phishing cannot beat.
WebAuthn replaces the shared secret with a key pair. The private key never leaves the user's device (or their password manager); the server stores only the public key. There is nothing to steal from your database and nothing for a user to type into a fake page.
Registration
- The server sends a random challenge and its relying party id (your domain).
- The browser asks the authenticator — Touch ID, Windows Hello, a security key, a password manager — to create a key pair for this origin.
- The device returns the public key and a signed attestation.
- The server stores the public key, the credential id, and a signature counter.
Authentication
- The server sends a fresh challenge.
- The device signs it — after a local gesture: a fingerprint, a face, a PIN, a tap.
- The server verifies the signature against the stored public key.
Why phishing fails
The browser includes the origin in the signed data, and the authenticator only offers credentials registered for that exact origin. On abc-secure-login.com, the user's abc.com passkey is not offered — there is nothing for them to click, and nothing they could be talked into typing. This is a structural guarantee, not user education.
Passkeys are WebAuthn plus sync
A passkey is a discoverable WebAuthn credential that syncs through iCloud Keychain, Google Password Manager or a password manager. That fixes the historical blocker: losing the device no longer means losing the account. It also means the security boundary includes the sync provider — a reasonable trade for most consumer products, and one some regulated environments will refuse.
Practicalities
- Verify the challenge, origin and RP id server-side. Never trust the client's word for any of them.
- Allow several credentials per user — a laptop, a phone, a hardware key.
- Keep a recovery path. Email-based or a second registered credential.
- The signature counter detects cloned authenticators, when the device provides one; synced passkeys often report zero.
Example
// Registration — the browser handles the device interaction
const credential = await navigator.credentials.create({
publicKey: {
challenge: base64urlToBuffer(options.challenge), // from your server
rp: { id: 'abc.com', name: 'SoundsCode' },
user: { id: userIdBuffer, name: '[email protected]', displayName: 'Alice' },
pubKeyCredParams: [{ alg: -7, type: 'public-key' }, // ES256
{ alg: -257, type: 'public-key' }], // RS256
authenticatorSelection: { residentKey: 'preferred', userVerification: 'preferred' },
timeout: 60000,
},
});
// Authentication
const assertion = await navigator.credentials.get({
publicKey: { challenge: base64urlToBuffer(options.challenge), rpId: 'abc.com' },
});When to use it
- A company eliminates password phishing on its admin console by requiring passkeys, since a lookalike domain cannot obtain the credential at all.
- A user registers a laptop, a phone and a hardware key, so losing any one device does not lock them out.
- A bank keeps a hardware security key requirement for staff because synced passkeys place trust in a consumer cloud account.
More examples
Server-side registration and verification
expectedOrigin is the line that makes this phishing-resistant. Relaxing it to accept any origin — a tempting shortcut in development — removes the entire benefit.
import {
generateRegistrationOptions, verifyRegistrationResponse,
generateAuthenticationOptions, verifyAuthenticationResponse,
} from '@simplewebauthn/server';
const RP_ID = 'abc.com'; // your domain — permanent
const ORIGIN = 'https://abc.com';
// --- registration: issue a challenge ---
app.post('/webauthn/register/options', session, async (req, res) => {
const existing = await db.credentials.findByUser(req.user.id);
const options = await generateRegistrationOptions({
rpName: 'SoundsCode', rpID: RP_ID,
userName: req.user.email,
attestationType: 'none',
// Stops the user registering the same authenticator twice
excludeCredentials: existing.map((c) => ({ id: c.credentialId, type: 'public-key' })),
authenticatorSelection: { residentKey: 'preferred', userVerification: 'preferred' },
});
// The challenge MUST be remembered server-side and used exactly once.
await redis.set(`webauthn:reg:${req.user.id}`, options.challenge, 'EX', 300);
res.json(options);
});
// --- registration: verify what the device returned ---
app.post('/webauthn/register/verify', session, async (req, res) => {
const expectedChallenge = await redis.getdel(`webauthn:reg:${req.user.id}`);
if (!expectedChallenge) return res.status(400).json({ error: 'challenge_expired' });
const { verified, registrationInfo } = await verifyRegistrationResponse({
response: req.body,
expectedChallenge,
expectedOrigin: ORIGIN, // ← the anti-phishing check
expectedRPID: RP_ID,
});
if (!verified) return res.status(400).json({ error: 'verification_failed' });
await db.credentials.insert({
userId: req.user.id,
credentialId: registrationInfo.credentialID,
publicKey: registrationInfo.credentialPublicKey, // public only
counter: registrationInfo.counter,
deviceType: registrationInfo.credentialDeviceType, // 'singleDevice' | 'multiDevice'
label: req.body.label ?? 'Security key',
});
res.status(204).end();
});
// --- authentication ---
app.post('/webauthn/login/verify', async (req, res) => {
const expectedChallenge = await redis.getdel(`webauthn:auth:${req.body.flowId}`);
const cred = await db.credentials.findByCredentialId(req.body.id);
if (!cred || !expectedChallenge) return res.status(400).json({ error: 'invalid_request' });
const { verified, authenticationInfo } = await verifyAuthenticationResponse({
response: req.body,
expectedChallenge, expectedOrigin: ORIGIN, expectedRPID: RP_ID,
credential: {
id: cred.credentialId, publicKey: cred.publicKey, counter: cred.counter,
},
});
if (!verified) return res.status(401).json({ error: 'verification_failed' });
// A counter that did not advance can indicate a cloned authenticator.
// Synced passkeys legitimately report 0 — only enforce when it was non-zero.
if (cred.counter > 0 && authenticationInfo.newCounter <= cred.counter) {
await alertSecurityTeam('possible_cloned_authenticator', cred.id);
return res.status(401).json({ error: 'verification_failed' });
}
await db.credentials.update(cred.id, { counter: authenticationInfo.newCounter });
res.cookie('sid', await createSession(cred.userId, req, { mfa: true }), COOKIE_OPTIONS);
res.status(204).end();
});Progressive rollout without locking anyone out
Step 4 is where teams get stuck, and correctly so: passwordless is only safe once account recovery is as phishing-resistant as the login it replaced.
// Offer passkeys where they exist, keep every other route open.
async function passkeysAvailable() {
if (!window.PublicKeyCredential) return false;
const [platform, autofill] = await Promise.all([
PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable?.() ?? false,
PublicKeyCredential.isConditionalMediationAvailable?.() ?? false,
]);
return { platform, autofill };
}
// Conditional UI: the browser offers the passkey inside the username field,
// with no button for the user to find.
async function enablePasskeyAutofill() {
const { autofill } = await passkeysAvailable();
if (!autofill) return;
const options = await fetch('/webauthn/login/options').then((r) => r.json());
const assertion = await navigator.credentials.get({
publicKey: { ...options, challenge: base64urlToBuffer(options.challenge) },
mediation: 'conditional', // ← shows up in the autofill dropdown
});
if (assertion) await completeLogin(assertion);
}
// <input name="email" autocomplete="username webauthn">
// Ladder to climb, never all at once:
// 1. passkey as an OPTIONAL second factor
// 2. passkey as an alternative to the password
// 3. passkey only, for staff / admin accounts
// 4. passkey only, everywhere — once recovery is genuinely solved
Discussion