iwantcoding.com
🔥 Daily 👥 Rooms 🏆 Top Log in Sign up

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…