WebAuthn & Passkeys: Building Passwordless Authentication for Production 2026

安全指南

Why Passwords Are Finally Dead

A user types "Summer2024!" for the 47th service. The database stores $2b$10$... — bcrypt-hashed, salted, secure. Then a phishing email arrives, the user enters that password on a lookalike domain, and the attacker authenticates as them. No amount of hashing saves you from credential reuse and phishing.

WebAuthn solves this at the protocol level. Instead of a shared secret, the browser generates a public-private key pair. The server stores only the public key. The private key never leaves the user's device. Authentication becomes a cryptographic proof: the user proves they control the private key by signing a challenge from the server.

In 2026, every major platform supports WebAuthn. Passkeys — Apple and Google's consumer-friendly branding — have made the UX seamless. This guide covers the complete implementation, from the cryptographic underpinnings to production deployment.


How WebAuthn Actually Works

The Two Ceremonies

WebAuthn has exactly two operations:

Ceremony Purpose What Happens
Registration (navigator.credentials.create) Create a new credential Browser generates key pair → sends public key to server
Authentication (navigator.credentials.get) Prove identity Server sends challenge → browser signs with private key → server verifies

The Cryptographic Flow

Registration:
  Client                          Server
    |                               |
    |--- GET /register/options ---->|
    |                               |--- Generate challenge (random 32 bytes)
    |<-- challenge + user info -----|
    |                               |
  Browser generates:                |
    - ECDSA P-256 key pair         |
    - credential ID                |
    - attestation signature        |
    |                               |
    |--- publicKey + signature ---->|
    |                               |--- Verify attestation
    |                               |--- Store publicKey + credentialID
    |<-- 200 OK --------------------|

Authentication:
  Client                          Server
    |                               |
    |--- GET /login/options ------->|
    |                               |--- Generate challenge + list allowed credential IDs
    |<-- challenge + allowList -----|
    |                               |
  Browser:                          |
    - User verifies (biometric/PIN) |
    - Signs challenge with private key |
    |                               |
    |--- signature + credentialID ->|
    |                               |--- Fetch stored public key
    |                               |--- Verify signature against challenge
    |                               |--- Update signCount
    |<-- session token -------------|

The critical insight: the private key never leaves the authenticator. Not during registration, not during authentication. The server never sees it. Even if your database is compromised, attackers can't impersonate users because they don't have the private keys.


Server-Side Implementation (Node.js)

Dependencies

npm install @simplewebauthn/server @simplewebauthn/browser

Registration: Generate Options

// server/webauthn.ts
import {
  generateRegistrationOptions,
  verifyRegistrationResponse,
  generateAuthenticationOptions,
  verifyAuthenticationResponse,
} from "@simplewebauthn/server";
import type {
  AuthenticatorTransportFuture,
  CredentialDeviceType,
} from "@simplewebauthn/server";

// Configuration: MUST match your deployment
const RP_NAME = "MyApp";
const RP_ID = "myapp.com"; // Your domain — no protocol, no port
const RP_ORIGIN = "https://myapp.com";
// POST /api/webauthn/register/options
export async function generateRegisterOptions(userId: string, userName: string) {
  // Fetch existing credentials to prevent duplicate registration
  const existingCredentials = await db.credentials.findMany({
    where: { userId },
    select: { credentialId: true },
  });

  const options = await generateRegistrationOptions({
    rpName: RP_NAME,
    rpID: RP_ID,
    userName,
    userDisplayName: userName,
    // Attestation: decide what assurance you need about the authenticator
    attestationType: "none", // "none" | "indirect" | "direct"
    excludeCredentials: existingCredentials.map((cred) => ({
      id: cred.credentialId,
      type: "public-key" as const,
    })),
    authenticatorSelection: {
      // residentKey: "required" for Passkeys (discoverable credentials)
      residentKey: "required",
      // User verification: "required" means biometric or PIN
      userVerification: "preferred",
    },
    // Supported algorithms: ES256 and RS256 are universally supported
    supportedAlgorithmIDs: [-7, -257], // ES256, RS256
  });

  // Store challenge temporarily — needed for verification
  await db.challenges.create({
    data: {
      userId,
      challenge: options.challenge,
      expiresAt: new Date(Date.now() + 5 * 60 * 1000), // 5 minutes
    },
  });

  return options;
}

The residentKey: "required" decision: This enables Passkeys — the credential is "discoverable," meaning the browser can list available credentials without the server providing a credential ID first. Without this, you need to send allowCredentials during authentication, which requires the user to enter their username first (the traditional two-step login).

Registration: Verify Response

// POST /api/webauthn/register/verify
export async function verifyRegistration(
  userId: string,
  response: AuthenticatorAttestationResponse
) {
  // Retrieve the challenge we stored during options generation
  const storedChallenge = await db.challenges.findFirst({
    where: { userId, expiresAt: { gt: new Date() } },
    orderBy: { createdAt: "desc" },
  });

  if (!storedChallenge) {
    throw new Error("Challenge expired or not found. Restart registration.");
  }

  const verification = await verifyRegistrationResponse({
    response,
    expectedChallenge: storedChallenge.challenge,
    expectedOrigin: RP_ORIGIN,
    expectedRPID: RP_ID,
    requireUserVerification: false,
  });

  const { verified, registrationInfo } = verification;

  if (!verified || !registrationInfo) {
    throw new Error("Registration verification failed");
  }

  const {
    credentialID,
    credentialPublicKey,
    counter,
    credentialDeviceType,
    credentialBackedUp,
  } = registrationInfo;

  // Store the credential
  await db.credentials.create({
    data: {
      userId,
      credentialId: Buffer.from(credentialID).toString("base64url"),
      publicKey: Buffer.from(credentialPublicKey).toString("base64url"),
      counter,
      deviceType: credentialDeviceType as CredentialDeviceType,
      backedUp: credentialBackedUp,
      transports: response.response.getTransports()?.join(",") || "",
    },
  });

  // Clean up used challenge
  await db.challenges.delete({ where: { id: storedChallenge.id } });

  return { verified: true };
}

Authentication: Generate Options

// POST /api/webauthn/login/options
export async function generateAuthOptions(userId?: string) {
  let allowCredentials;

  if (userId) {
    // Conditional UI / autofill: we know the user
    const credentials = await db.credentials.findMany({
      where: { userId },
      select: { credentialId: true, transports: true },
    });

    allowCredentials = credentials.map((cred) => ({
      id: cred.credentialId,
      type: "public-key" as const,
      transports: cred.transports?.split(",") as AuthenticatorTransport[],
    }));
  }
  // If no userId, this becomes a discoverable credential flow (Passkeys)
  // The browser will show a list of available credentials for this RP

  const options = await generateAuthenticationOptions({
    rpID: RP_ID,
    allowCredentials,
    userVerification: "preferred",
  });

  // Store challenge
  await db.challenges.create({
    data: {
      challenge: options.challenge,
      expiresAt: new Date(Date.now() + 5 * 60 * 1000),
    },
  });

  return options;
}

Authentication: Verify Response

// POST /api/webauthn/login/verify
export async function verifyAuthentication(
  response: AuthenticatorAssertionResponse
) {
  const credentialId = response.id;

  // Fetch stored credential
  const credential = await db.credentials.findFirst({
    where: { credentialId },
  });

  if (!credential) {
    throw new Error("Credential not found");
  }

  // Fetch latest challenge
  const storedChallenge = await db.challenges.findFirst({
    where: { expiresAt: { gt: new Date() } },
    orderBy: { createdAt: "desc" },
  });

  if (!storedChallenge) {
    throw new Error("Challenge expired. Please try again.");
  }

  const verification = await verifyAuthenticationResponse({
    response,
    expectedChallenge: storedChallenge.challenge,
    expectedOrigin: RP_ORIGIN,
    expectedRPID: RP_ID,
    authenticator: {
      credentialID: credential.credentialId,
      credentialPublicKey: new Uint8Array(
        Buffer.from(credential.publicKey, "base64url")
      ),
      counter: credential.counter,
      transports: credential.transports?.split(",") as AuthenticatorTransportFuture[],
    },
    requireUserVerification: false,
  });

  const { verified, authenticationInfo } = verification;

  if (!verified) {
    throw new Error("Authentication verification failed");
  }

  // Update counter (prevents replay attacks)
  await db.credentials.update({
    where: { id: credential.id },
    data: {
      counter: authenticationInfo.newCounter,
      lastUsedAt: new Date(),
    },
  });

  // Clean up challenge
  await db.challenges.delete({ where: { id: storedChallenge.id } });

  return { verified: true, userId: credential.userId };
}

Client-Side Implementation

// lib/webauthn.ts
import {
  startRegistration,
  startAuthentication,
} from "@simplewebauthn/browser";

export async function registerPasskey(userId: string, userName: string) {
  // Step 1: Get registration options from server
  const optionsResp = await fetch("/api/webauthn/register/options", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ userId, userName }),
  });
  const options = await optionsResp.json();

  // Step 2: Create credential in browser
  let attResp: Awaited<ReturnType<typeof startRegistration>>;
  try {
    attResp = await startRegistration(options);
  } catch (error: any) {
    if (error.name === "InvalidStateError") {
      // User cancelled the operation — normal
      return { cancelled: true };
    }
    throw error;
  }

  // Step 3: Send attestation to server for verification
  const verifyResp = await fetch("/api/webauthn/register/verify", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ userId, response: attResp }),
  });

  const { verified } = await verifyResp.json();
  return { verified };
}

export async function authenticateWithPasskey() {
  // Step 1: Get authentication options (no userId — discoverable credentials)
  const optionsResp = await fetch("/api/webauthn/login/options", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({}), // Empty — use discoverable credentials
  });
  const options = await optionsResp.json();

  // Step 2: Get assertion from authenticator
  let authResp: Awaited<ReturnType<typeof startAuthentication>>;
  try {
    authResp = await startAuthentication(options, true); // true = use browser autofill
  } catch (error: any) {
    if (error.name === "NotAllowedError") {
      return { cancelled: true };
    }
    throw error;
  }

  // Step 3: Verify with server
  const verifyResp = await fetch("/api/webauthn/login/verify", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ response: authResp }),
  });

  const result = await verifyResp.json();
  return result;
}

React Component

// components/PasskeyButton.tsx
"use client";

import { startAuthentication } from "@simplewebauthn/browser";
import { useState } from "react";

export function PasskeyLoginButton() {
  const [loading, setLoading] = useState(false);
  const [error, setError] = useState<string | null>(null);

  const handlePasskeyLogin = async () => {
    setLoading(true);
    setError(null);

    try {
      // Browser Autofill UI (Conditional Mediation)
      // This shows the passkey selector automatically when the page loads
      const autoLoginResp = await startAuthentication({ challenge: "..." }, true);

      if (!autoLoginResp) return; // User dismissed

      const verifyResp = await fetch("/api/webauthn/login/verify", {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ response: autoLoginResp }),
      });

      if (verifyResp.ok) {
        window.location.href = "/dashboard";
      }
    } catch (err: any) {
      setError(err.message || "Authentication failed");
    } finally {
      setLoading(false);
    }
  };

  return (
    <div>
      <button
        onClick={handlePasskeyLogin}
        disabled={loading}
        className="passkey-btn"
      >
        <svg width="20" height="20" viewBox="0 0 24 24">
          <path d="M12 2C9.243 2 7 4.243 7 7v3H6c-1.103 0-2 .897-2 2v8c0 1.103.897 2 2 2h12c1.103 0 2-.897 2-2v-8c0-1.103-.897-2-2-2h-1V7c0-2.757-2.243-5-5-5z"/>
        </svg>
        {loading ? "Verifying..." : "Sign in with Passkey"}
      </button>
      {error && <p className="error">{error}</p>}
    </div>
  );
}

Attestation: Verifying Authenticator Authenticity

Attestation proves that the key pair was generated by a genuine authenticator. There are three levels:

Attestation Type What It Proves Privacy Impact When to Use
none Nothing about the authenticator Best privacy Most consumer apps
indirect Authenticator is genuine, but anonymized Good privacy Enterprise with policy requirements
direct Exact authenticator model and make Poor privacy High-security government/military

For 90% of applications, none is sufficient. You're verifying the user controls the private key, not authenticating their hardware.

// When you need strict attestation
const options = await generateRegistrationOptions({
  // ...
  attestationType: "direct",
  attestationFormats: ["packed", "tpm", "android-safetynet"],
});

Platform vs Cross-Platform Authenticators

Type Examples Pros Cons
Platform Touch ID, Face ID, Windows Hello, Android biometric Seamless UX, no extra device Tied to one device, loses credentials if device wiped
Cross-platform YubiKey, Titan Security Key, smartphone as passkey Portable, can be backed up Extra hardware, can be lost

Passkeys Solve the Portability Problem

Passkeys extend WebAuthn by syncing credentials across devices through platform accounts (iCloud Keychain, Google Password Manager). This is what makes them consumer-viable:

Traditional WebAuthn:
  - Private key stays on one device
  - Lose the device = lose access to accounts

Passkeys (synced):
  - Private key end-to-end encrypted in cloud
  - Available on all devices logged into same platform account
  - Backup and recovery built into the OS

The credentialBackedUp flag in our stored credential tells us whether the credential syncs. This is important for recovery flows: synced credentials can survive device loss.


Security Considerations

Replay Attack Prevention

The challenge + signature counter pattern prevents replay attacks:

// During verification, check the counter
if (authenticationInfo.newCounter <= credential.counter) {
  throw new Error("Possible replay attack detected");
}

Each authentication increments the counter. If a signature is replayed, the counter won't have advanced, and verification fails.

Phishing Resistance

WebAuthn's phishing resistance comes from origin binding. The browser includes rpId in the credential creation. During authentication, it verifies the requesting origin matches. A phishing site on goog1e.com can't use a credential created for google.com.

// The server enforces origin checking
const verification = await verifyAuthenticationResponse({
  // ...
  expectedOrigin: RP_ORIGIN, // "https://myapp.com"
  expectedRPID: RP_ID,       // "myapp.com"
});

User Verification

Level Description UX
discouraged No user gesture required Immediate, less secure
preferred Ask if available, skip if not Best balance
required Must verify (biometric/PIN) Most secure, may fail on some devices
// For sensitive operations (payments, admin), require verification
const options = await generateAuthenticationOptions({
  userVerification: "required",
});

// For general login, preferred is a good default
const options = await generateAuthenticationOptions({
  userVerification: "preferred",
});

The Conditional UI (Autofill) Pattern

The best Passkey UX doesn't involve a "Sign in with Passkey" button at all. Conditional UI (formerly "autofill") shows the passkey selector when the user focuses the username field:

// On page load, start conditional mediation
useEffect(() => {
  const abortController = new AbortController();

  const options = await fetch("/api/webauthn/login/options", {
    method: "POST",
    body: JSON.stringify({}),
  }).then((r) => r.json());

  // This doesn't prompt — it waits for user interaction with a field
  startAuthentication(options, true) // true = useBrowserAutofill
    .then((result) => {
      // User selected a passkey
      verifyAndLogin(result);
    })
    .catch((err) => {
      if (err.name !== "AbortError") {
        console.error("Conditional UI error:", err);
      }
    });

  return () => abortController.abort();
}, []);

When the user clicks the username input, the browser shows available passkeys as autofill suggestions. One tap, face scan, logged in. This is the "magic" UX that makes Passkeys genuinely better than passwords.


Recovery Flow: What If the User Loses All Devices?

WebAuthn's strength is also its weakness: the private key is device-bound. Recovery requires planning:

Strategy 1: Multi-Credential

Encourage users to register multiple credentials:

// UI: "Add another passkey"
// Each credential is independently usable
// Register a platform passkey (iCloud/Google sync) + a cross-platform key (YubiKey)

Strategy 2: Recovery Codes

// On registration, generate 10 one-time recovery codes
const recoveryCodes = Array.from({ length: 10 }, () =>
  randomBytes(8).toString("hex")
);

const hashedCodes = await Promise.all(
  recoveryCodes.map((code) => bcrypt.hash(code, 12))
);

await db.recoveryCodes.createMany({
  data: hashedCodes.map((hash) => ({ userId, hash })),
});

// Show codes once, user saves them
return { recoveryCodes };

Strategy 3: Fallback to Verified Email/Phone

// If no passkey available:
// 1. Send magic link to verified email
// 2. On click, create a new passkey for current device
// 3. Revoke all old credentials (optional, for security)

Production Checklist

# Item Why
1 Use RP_ID matching your domain Mismatch = all operations fail
2 HTTPS everywhere (including localhost exception for dev) WebAuthn requires secure context
3 Challenge TTL ≤ 5 minutes Prevents challenge reuse
4 Track and verify signCount Prevents replay attacks
5 Use @simplewebauthn for parsing CBOR/ASN.1 parsing is error-prone
6 Store publicKey as base64url (not hex) SimpleWebAuthn expects this format
7 Set residentKey: "required" for Passkeys Required for discoverable credentials
8 Handle InvalidStateError gracefully User cancelled — not an error
9 Test on real hardware Emulators don't have platform authenticators
10 Provide password fallback during migration "Don't break existing users"
11 Log attestation failures for monitoring Detect attack attempts
12 Implement rate limiting on challenge endpoints Prevent DoS

Common Errors and Solutions

Error Cause Solution
NotAllowedError: The operation either timed out or was not allowed User cancelled, or no platform authenticator available Show a "retry" button; check PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable()
SecurityError: The operation is insecure Not HTTPS, or localhost without proper config Verify HTTPS certificate; add localhost exception in dev
UnknownError: An unknown error occurred while processing the operation RP ID mismatch between options and verification Verify rpID is identical in generate and verify calls
InvalidStateError: The authenticator was previously registered Duplicate credential registration Use excludeCredentials in registration options
ConstraintError Trying to register credential that already exists Catch and handle gracefully — offer to use existing passkey
Browser autofill doesn't show passkeys allowCredentials is empty or wrong, conditional mediation not supported Use {} (empty options with no allowCredentials) for discoverable flow

Database Schema

CREATE TABLE credentials (
  id TEXT PRIMARY KEY,
  user_id TEXT NOT NULL REFERENCES users(id),
  credential_id TEXT NOT NULL UNIQUE,
  public_key TEXT NOT NULL,
  counter INTEGER NOT NULL DEFAULT 0,
  device_type TEXT NOT NULL,      -- 'singleDevice' | 'multiDevice'
  backed_up BOOLEAN NOT NULL DEFAULT false,
  transports TEXT,                 -- 'usb,ble,nfc,internal'
  created_at TIMESTAMP DEFAULT NOW(),
  last_used_at TIMESTAMP,
  name TEXT                        -- User-given name: "My iPhone" / "YubiKey 5"
);

CREATE TABLE challenges (
  id TEXT PRIMARY KEY,
  user_id TEXT,
  challenge TEXT NOT NULL,
  expires_at TIMESTAMP NOT NULL,
  created_at TIMESTAMP DEFAULT NOW()
);

CREATE INDEX idx_credentials_user_id ON credentials(user_id);
CREATE INDEX idx_credentials_credential_id ON credentials(credential_id);
CREATE INDEX idx_challenges_expires_at ON challenges(expires_at);

Summary: WebAuthn and Passkeys eliminate phishing and credential stuffing at the protocol level. The core pattern is simple: the browser generates keys, the server stores only the public half, and authentication is a cryptographic signing operation. The complexity lives in the details — attestation verification, counter checking, challenge management, and recovery flows. For new projects, start with Passkeys-only auth plus recovery codes. For existing projects, add Passkeys alongside passwords and gradually increase adoption. The security improvement is categorical: credential reuse and phishing become impossible by design, not by policy.


Online Tools

  • Hash Calculator — Generate and verify challenge hashes for debugging WebAuthn flows
  • Base64 Encode/Decode — Handle base64url-encoded credential IDs and public keys
  • JSON Formatter — Format and inspect WebAuthn options and response payloads

Try these browser-local tools — no sign-up required →

#WebAuthn#Passkeys#FIDO2#无密码认证#安全#认证#2026