WebAuthn 与 Passkeys 实战:构建生产级无密码认证系统

安全指南

密码终于寿终正寝了

一个用户在第 47 个服务上输入了 "Summer2024!"。数据库里存着 $2b$10$...——bcrypt 哈希加盐,安全可靠。然后一封钓鱼邮件来了,用户在一个仿冒域名上输入了那个密码,攻击者就以用户的身份登录了。再强的哈希算法也救不了凭证复用和钓鱼攻击。

WebAuthn 在协议层面解决了这个问题。不再使用共享密钥,浏览器生成公私钥对。服务器只存储公钥。私钥永远不会离开用户的设备。认证变成了密码学证明:用户通过签名服务器发来的随机挑战,证明自己掌控着私钥。

2026 年,所有主流平台都支持 WebAuthn。Passkeys——Apple 和 Google 面向消费者的品牌包装——让用户体验变得无缝。这篇指南覆盖了从密码学原理到生产部署的完整实现。


WebAuthn 到底怎么运作的

两大仪式

WebAuthn 只有两个操作:

仪式 用途 发生了什么
注册(navigator.credentials.create) 创建新凭证 浏览器生成密钥对 → 将公钥发送给服务器
认证(navigator.credentials.get) 证明身份 服务器发送挑战 → 浏览器用私钥签名 → 服务器验证

密码学流程

注册流程:
  客户端                            服务器
    |                               |
    |--- GET /register/options ---->|
    |                               |--- 生成挑战(随机 32 字节)
    |<-- challenge + 用户信息 -------|
    |                               |
  浏览器生成:                         |
    - ECDSA P-256 密钥对              |
    - 凭证 ID                        |
    - 证明签名                        |
    |                               |
    |--- publicKey + signature ---->|
    |                               |--- 验证证明
    |                               |--- 存储 publicKey + credentialID
    |<-- 200 OK --------------------|

认证流程:
  客户端                            服务器
    |                               |
    |--- GET /login/options ------->|
    |                               |--- 生成 challenge + 允许的 credential ID 列表
    |<-- challenge + allowList -----|
    |                               |
  浏览器:                            |
    - 用户验证(生物识别/PIN)          |
    - 用私钥签名 challenge             |
    |                               |
    |--- signature + credentialID ->|
    |                               |--- 取出存储的公钥
    |                               |--- 验证签名与 challenge
    |                               |--- 更新 signCount
    |<-- session token -------------|

关键洞察:私钥从未离开认证器。注册时不离开,认证时也不离开。服务器永远看不到它。即使你的数据库被拖库,攻击者也冒充不了任何用户,因为他们没有私钥。


服务端实现(Node.js)

依赖

npm install @simplewebauthn/server @simplewebauthn/browser

注册:生成选项

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

// 配置项:务必匹配你的部署环境
const RP_NAME = "MyApp";
const RP_ID = "myapp.com"; // 你的域名——无协议,无端口
const RP_ORIGIN = "https://myapp.com";
// POST /api/webauthn/register/options
export async function generateRegisterOptions(userId: string, userName: string) {
  // 获取已有凭证,防止重复注册
  const existingCredentials = await db.credentials.findMany({
    where: { userId },
    select: { credentialId: true },
  });

  const options = await generateRegistrationOptions({
    rpName: RP_NAME,
    rpID: RP_ID,
    userName,
    userDisplayName: userName,
    // 证明类型:决定你对认证器的信任度要求
    attestationType: "none", // "none" | "indirect" | "direct"
    excludeCredentials: existingCredentials.map((cred) => ({
      id: cred.credentialId,
      type: "public-key" as const,
    })),
    authenticatorSelection: {
      // residentKey: "required" 启用 Passkeys(可发现凭证)
      residentKey: "required",
      // 用户验证:"required" 表示要求生物识别或 PIN
      userVerification: "preferred",
    },
    // 支持的算法:ES256 和 RS256 都是通用支持
    supportedAlgorithmIDs: [-7, -257], // ES256, RS256
  });

  // 暂存 challenge——验证时需要
  await db.challenges.create({
    data: {
      userId,
      challenge: options.challenge,
      expiresAt: new Date(Date.now() + 5 * 60 * 1000), // 5 分钟过期
    },
  });

  return options;
}

residentKey: "required" 的决策点:它启用了 Passkeys——凭证变得"可发现",意味着浏览器可以在不需要服务器提供 credential ID 的情况下列出可用凭证。不设置它,认证时就需要传 allowCredentials,这要求用户先输入用户名(传统的两步登录)。

注册:验证响应

// POST /api/webauthn/register/verify
export async function verifyRegistration(
  userId: string,
  response: AuthenticatorAttestationResponse
) {
  // 获取注册时存储的 challenge
  const storedChallenge = await db.challenges.findFirst({
    where: { userId, expiresAt: { gt: new Date() } },
    orderBy: { createdAt: "desc" },
  });

  if (!storedChallenge) {
    throw new Error("Challenge 已过期或未找到。请重新开始注册。");
  }

  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("注册验证失败");
  }

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

  // 存储凭证
  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(",") || "",
    },
  });

  // 清理已使用的 challenge
  await db.challenges.delete({ where: { id: storedChallenge.id } });

  return { verified: true };
}

认证:生成选项

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

  if (userId) {
    // 条件 UI / 自动填充:我们知道用户是谁
    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[],
    }));
  }
  // 如果没有 userId,走可发现凭证流程(Passkeys)
  // 浏览器会展示当前 RP 可用的凭证列表

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

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

  return options;
}

认证:验证响应

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

  // 获取存储的凭证
  const credential = await db.credentials.findFirst({
    where: { credentialId },
  });

  if (!credential) {
    throw new Error("凭证未找到");
  }

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

  if (!storedChallenge) {
    throw new Error("Challenge 已过期,请重试。");
  }

  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("身份验证失败");
  }

  // 更新计数器(防止重放攻击)
  await db.credentials.update({
    where: { id: credential.id },
    data: {
      counter: authenticationInfo.newCounter,
      lastUsedAt: new Date(),
    },
  });

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

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

客户端实现

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

export async function registerPasskey(userId: string, userName: string) {
  // 第一步:从服务器获取注册选项
  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();

  // 第二步:在浏览器中创建凭证
  let attResp: Awaited<ReturnType<typeof startRegistration>>;
  try {
    attResp = await startRegistration(options);
  } catch (error: any) {
    if (error.name === "InvalidStateError") {
      // 用户取消了操作——正常行为
      return { cancelled: true };
    }
    throw error;
  }

  // 第三步:将证明发送给服务器验证
  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() {
  // 第一步:获取认证选项(无 userId——使用可发现凭证)
  const optionsResp = await fetch("/api/webauthn/login/options", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({}),
  });
  const options = await optionsResp.json();

  // 第二步:从认证器获取断言
  let authResp: Awaited<ReturnType<typeof startAuthentication>>;
  try {
    authResp = await startAuthentication(options, true); // true = 使用浏览器自动填充
  } catch (error: any) {
    if (error.name === "NotAllowedError") {
      return { cancelled: true };
    }
    throw error;
  }

  // 第三步:与服务器验证
  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 组件

// 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 {
      const autoLoginResp = await startAuthentication({ challenge: "..." }, true);

      if (!autoLoginResp) return; // 用户关闭了选择器

      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 || "认证失败");
    } 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 ? "验证中..." : "使用 Passkey 登录"}
      </button>
      {error && <p className="error">{error}</p>}
    </div>
  );
}

证明(Attestation):验证认证器真伪

证明用于确认密钥对是在真实认证器中生成的。有三个级别:

证明类型 它能证明什么 隐私影响 使用场景
none 不提供认证器相关信息 最佳隐私 大多数消费者应用
indirect 认证器是真品(匿名化) 良好隐私 有策略要求的企业应用
direct 精确的认证器型号和制造商 隐私较差 高安全性的政府/军事场景

90% 的应用场景下,none 就够了。你验证的是用户掌控私钥,而不是认证他们的硬件。

// 需要严格证明的场景
const options = await generateRegistrationOptions({
  // ...
  attestationType: "direct",
  attestationFormats: ["packed", "tpm", "android-safetynet"],
});

平台认证器 vs 跨平台认证器

类型 示例 优点 缺点
平台 Touch ID、Face ID、Windows Hello、安卓生物识别 极致体验,无需额外设备 绑定一台设备,设备重置会丢失凭证
跨平台 YubiKey、Titan 安全密钥、手机作为 Passkey 便携、可备份 需要额外硬件,可能丢失

Passkeys 解决了便携性问题

Passkeys 扩展了 WebAuthn,通过平台账户(iCloud Keychain、Google Password Manager)跨设备同步凭证。这让它对消费者变得可行:

传统 WebAuthn:
  - 私钥停留在一台设备上
  - 丢失设备 = 失去账户访问

Passkeys(同步版):
  - 私钥在云端端到端加密
  - 所有登录同一平台账户的设备均可使用
  - 备份和恢复由操作系统内置完成

我们存储的 credentialBackedUp 标记告诉我们凭证是否同步。这对恢复流程很重要:同步的凭证可以经受住设备丢失。


安全考量

防重放攻击

challenge + 签名计数器模式阻止重放攻击:

// 验证时检查计数器
if (authenticationInfo.newCounter <= credential.counter) {
  throw new Error("检测到可能的重放攻击");
}

每次认证计数器递增。如果签名被重放,计数器没有推进,验证就会失败。

防钓鱼

WebAuthn 的防钓鱼能力来自源绑定。浏览器在创建凭证时将 rpId 纳入其中。认证时,它会验证请求来源是否匹配。goog1e.com 上的钓鱼网站无法使用为 google.com 创建的凭证。

// 服务器强制执行源检查
const verification = await verifyAuthenticationResponse({
  // ...
  expectedOrigin: RP_ORIGIN, // "https://myapp.com"
  expectedRPID: RP_ID,       // "myapp.com"
});

用户验证等级

等级 描述 用户体验
discouraged 无需用户手势 即时响���,安全性较低
preferred 有则要求,无则跳过 最平衡的选择
required 必须验证(生物识别/PIN) 最安全,部分设备可能不支持
// 敏感操作(支付、管理后台)要求验证
const options = await generateAuthenticationOptions({
  userVerification: "required",
});

// 普通登录,preferred 是好的默认值
const options = await generateAuthenticationOptions({
  userVerification: "preferred",
});

条件 UI(自动填充)模式

最好的 Passkey 体验根本不需要"使用 Passkey 登录"按钮。条件 UI(之前叫 autofill)在用户点击用户名字段时就展示 passkey 选择器:

// 页面加载时,启动条件中介
useEffect(() => {
  const abortController = new AbortController();

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

  // 这里不会弹窗——它等待用户与输入字段交互
  startAuthentication(options, true) // true = useBrowserAutofill
    .then((result) => {
      // 用户选择了 passkey
      verifyAndLogin(result);
    })
    .catch((err) => {
      if (err.name !== "AbortError") {
        console.error("条件 UI 错误:", err);
      }
    });

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

当用户点击用户名字段时,浏览器会将可用的 passkeys 显示为自动填充建议。点一下、扫个脸、登录成功。这就是让 Passkeys 真正优于密码的"魔法"体验。


恢复流程:用户丢了所有设备怎么办?

WebAuthn 的优势也是它的弱点:私钥绑定在设备上。恢复需要提前规划:

策略一:多凭证

鼓励用户注册多个凭证:

// UI:"添加另一个 Passkey"
// 每个凭证都可以独立使用
// 注册一个平台 passkey(iCloud/Google 同步)+ 一个跨平台密钥(YubiKey)

策略二:恢复码

// 注册时,生成 10 个一次性恢复码
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 })),
});

// 只展示一次,用户自行保存
return { recoveryCodes };

策略三:兜底到已验证的邮箱/手机

// 如果没有可用的 passkey:
// 1. 发送魔法链接到已验证的邮箱
// 2. 点击后,为当前设备创建新的 passkey
// 3. 撤销所有旧凭证(可选,为了安全)

生产环境检查清单

# 检查项 原因
1 RP_ID 必须匹配域名 不匹配 = 所有操作失败
2 全站 HTTPS(开发环境 localhost 例外) WebAuthn 需要安全上下文
3 Challenge TTL ≤ 5 分钟 防止 challenge 复用
4 追踪并验证 signCount 防止重放攻击
5 @simplewebauthn 解析数据 CBOR/ASN.1 解析容易出错
6 用 base64url 存储 publicKey(不要用 hex) SimpleWebAuthn 期望这个格式
7 设置 residentKey: "required" 启用 Passkeys 可发现凭证的必要条件
8 优雅处理 InvalidStateError 用户取消——不算错误
9 在真实硬件上测试 模拟器没有平台认证器
10 迁移期间保留密码登录兜底 "不要破坏现有用户体验"
11 记录证明失败日志用于监控 检测攻击尝试
12 对 challenge 端点做速率限制 防止 DoS

常见错误与解决方案

错误 原因 解决方案
NotAllowedError: The operation timed out 用户取消,或没有平台认证器可用 显示"重试"按钮;检查 PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable()
SecurityError: The operation is insecure 非 HTTPS,或 localhost 配置不正确 验证 HTTPS 证书;开发环境添加 localhost 例外
UnknownError: An unknown error occurred 选项和验证之间的 RP ID 不匹配 确保 generate 和 verify 调用中的 rpID 完全一致
InvalidStateError: The authenticator was previously registered 重复注册凭证 在注册选项中传入 excludeCredentials
ConstraintError 试图注册已存在的凭证 捕获并优雅处理——提示使用已有 passkey
浏览器不显示 passkey 自动填充 allowCredentials 为空或有误,条件中介不支持 {}(空选项、无 allowCredentials)走可发现流程

数据库 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                        -- 用户命名:"我的 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);

总结:WebAuthn 和 Passkeys 在协议层面消除了钓鱼和凭证填充攻击。核心模式很简单:浏览器生成密钥,服务器只存储公钥,认证就是一次密码学签名操作。复杂性在细节中——证明验证、计数器检查、challenge 管理、恢复流程。新项目建议 Passkeys-only 认证加恢复码。已有项目建议在密码旁增加 Passkeys,逐步提高采用率。安全提升是质变的:凭证复用和钓鱼变得在设计上就不可行,而非靠策略约束。


在线工具

本站提供浏览器本地工具,免注册即可试用 →

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