Passkeys 与 WebAuthn 生产级实战:告别密码的完整工程方案

深入解析 Passkeys 底层原理与 WebAuthn API,从注册认证全流程到前后端完整实现,含 Node.js 服务端代码、数据库设计、多设备同步策略与从传统密码迁移的渐进式方案。

安全与密码 2026-06-01 18 分钟

Google 在 2025 年底宣布:Passkeys 已覆盖超过 8 亿账户,登录失败率相比密码降低了 69%。苹果、微软、GitHub、Shopify 等巨头全面押注这一技术,2026 年 WebAuthn Level 3 规范正式成为 W3C 推荐标准。Passkeys 不是未来趋势——它是正在发生的现实。如果你的应用还在用纯密码认证,现在是时候认真评估迁移到 Passkeys 的方案了。本文将从底层密码学原理讲起,给出完整的前后端实现代码和数据库设计,帮助你真正理解并落地这套方案。

🔐 一、Passkeys 底层原理:公钥密码学的工程化落地

1.1 Passkeys 到底是什么?

很多开发者把 Passkeys 当成「新的登录方式」,但它的本质是一套非对称密钥对认证系统。每个 Passkey 由一对密钥组成:

  • 私钥(Private Key):存储在用户设备的安全芯片中(Secure Enclave / TPM),永远不会离开设备
  • 公钥(Public Key):注册时发送到服务端存储,可以公开泄露也无安全风险

认证流程的核心逻辑:服务端发送一个随机挑战值(Challenge),用户设备用私钥签名,服务端用公钥验证签名。密码从未在网络上传输,服务端也不存储任何可泄露的凭证

📌 **记住:**Passkeys 的安全模型与 SSH 密钥认证完全一致——只是把它搬到了浏览器端,并用生物识别替代了 ssh-add 的口令保护。

1.2 三大关键术语辨析

术语 含义 技术实现 使用场景
Discoverable Credential 可发现凭证,私钥+用户标识都存在设备上 需要 residentKey: "required" 无用户名登录、自动填充
Non-Discoverable Credential 不可发现凭证,只存私钥 residentKey: "discouraged" 作为二次验证因素
Synced Passkey 同步 Passkey,通过云服务跨设备同步 iCloud Keychain、Google Password Manager、1Password 消费者应用首选方案

⚠️ **关键决策:**如果你面向普通消费者,必须支持 Synced Passkeys。要求用户在每台设备上单独注册 Passkey 会严重降低采纳率。residentKey: "preferred" 是大多数场景下的最佳选择。

1.3 WebAuthn 认证协议时序

WebAuthn 协议分为两个阶段(Ceremony):

注册阶段(Registration Ceremony):

  1. 客户端向服务端请求注册挑战值
  2. 服务端生成随机 Challenge,返回 PublicKeyCredentialCreationOptions
  3. 浏览器调用 navigator.credentials.create(),弹出生物识别验证
  4. 设备生成密钥对,返回公钥 + 签名的 Challenge
  5. 服务端验证签名并存储公钥

认证阶段(Authentication Ceremony):

  1. 客户端向服务端请求认证挑战值
  2. 服务端生成随机 Challenge,返回 PublicKeyCredentialRequestOptions
  3. 浏览器调用 navigator.credentials.get(),用户完成生物识别
  4. 设备用私钥签名 Challenge,返回签名
  5. 服务端用存储的公钥验证签名,签发 Session/JWT

💡 **提示:**Challenge 的唯一性是防止重放攻击的核心防线。每个 Challenge 只能使用一次,且必须在 60 秒内完成验证。

🚀 二、生产级实现:前后端完整代码

下面是一个完整的 WebAuthn 实现,使用 @simplewebauthn/server(服务端)和 @simplewebauthn/browser(客户端),这是目前最成熟的 WebAuthn 库组合。

2.1 服务端实现(Node.js + TypeScript)

# 安装依赖
npm install @simplewebauthn/server @simplewebauthn/types express
// server/webauthn.ts — WebAuthn 注册与认证的完整服务端实现
import {
  generateRegistrationOptions,
  generateAuthenticationOptions,
  verifyRegistrationResponse,
  verifyAuthenticationResponse,
} from '@simplewebauthn/server';
import type {
  RegistrationResponseJSON,
  AuthenticationResponseJSON,
  AuthenticatorTransportFuture,
} from '@simplewebauthn/types';

// === 配置 ===
const rpName = 'jsjson.com';
const rpID = 'jsjson.com';           // 生产环境改为实际域名
const expectedOrigin = `https://${rpID}`;

// === 内存存储(生产环境请替换为数据库) ===
interface StoredCredential {
  credentialID: Uint8Array;
  credentialPublicKey: Uint8Array;
  counter: number;
  transports?: AuthenticatorTransportFuture[];
}

interface UserRecord {
  id: string;
  username: string;
  displayName: string;
  credentials: Map<string, StoredCredential>;
  currentChallenge?: string;
}

const users = new Map<string, UserRecord>();

// === 注册:生成选项 ===
export async function startRegistration(userId: string, username: string) {
  let user = users.get(userId);
  if (!user) {
    user = {
      id: userId,
      username,
      displayName: username,
      credentials: new Map(),
    };
    users.set(userId, user);
  }

  const excludeCredentials = [...user.credentials.values()].map((cred) => ({
    id: Buffer.from(cred.credentialID).toString('base64url'),
    type: 'public-key' as const,
    transports: cred.transports,
  }));

  const options = await generateRegistrationOptions({
    rpName,
    rpID,
    userID: new TextEncoder().encode(userId),
    userName: username,
    userDisplayName: username,
    attestationType: 'none',        // 不需要设备证明,隐私优先
    authenticatorSelection: {
      residentKey: 'preferred',      // 支持同步 Passkey
      userVerification: 'preferred',
    },
    excludeCredentials,              // 排除已注册的凭证
  });

  user.currentChallenge = options.challenge;
  return options;
}

// === 注册:验证响应 ===
export async function finishRegistration(
  userId: string,
  response: RegistrationResponseJSON
) {
  const user = users.get(userId);
  if (!user || !user.currentChallenge) {
    throw new Error('用户不存在或 Challenge 已过期');
  }

  const verification = await verifyRegistrationResponse({
    response,
    expectedChallenge: user.currentChallenge,
    expectedOrigin,
    expectedRPID: rpID,
  });

  if (!verification.verified || !verification.registrationInfo) {
    throw new Error('注册验证失败');
  }

  const { credential } = verification.registrationInfo;
  // 存储公钥和计数器
  user.credentials.set(credential.id, {
    credentialID: credential.publicKey,  // simplewebauthn v10+ 处理方式
    credentialPublicKey: credential.publicKey,
    counter: credential.counter,
    transports: response.response.transports,
  });

  user.currentChallenge = undefined;
  return { verified: true, credentialId: credential.id };
}

// === 认证:生成选项 ===
export async function startAuthentication(userId?: string) {
  const options = await generateAuthenticationOptions({
    rpID,
    userVerification: 'preferred',
    ...(userId ? {
      allowCredentials: [...(users.get(userId)?.credentials.values() || [])].map(
        (cred) => ({
          id: Buffer.from(cred.credentialID).toString('base64url'),
          type: 'public-key' as const,
          transports: cred.transports,
        })
      ),
    } : {}),
    // 不传 allowCredentials 时,浏览器会显示所有可用 Passkey(Discoverable Credential)
  });

  if (userId) {
    const user = users.get(userId);
    if (user) user.currentChallenge = options.challenge;
  }
  return options;
}

// === 认证:验证响应 ===
export async function finishAuthentication(
  response: AuthenticationResponseJSON
) {
  // 从凭据ID反查用户(生产环境从数据库查询)
  let matchedUser: UserRecord | undefined;
  for (const user of users.values()) {
    for (const cred of user.credentials.values()) {
      if (Buffer.from(cred.credentialID).toString('base64url') === response.id) {
        matchedUser = user;
        break;
      }
    }
    if (matchedUser) break;
  }

  if (!matchedUser || !matchedUser.currentChallenge) {
    throw new Error('认证失败:Challenge 已过期');
  }

  const credential = matchedUser.credentials.get(response.id);
  if (!credential) throw new Error('凭证不存在');

  const verification = await verifyAuthenticationResponse({
    response,
    expectedChallenge: matchedUser.currentChallenge,
    expectedOrigin,
    expectedRPID: rpID,
    credential: {
      id: response.id,
      publicKey: credential.credentialPublicKey,
      counter: credential.counter,
      transports: credential.transports,
    },
  });

  if (!verification.verified) {
    throw new Error('认证验证失败');
  }

  // 更新 counter(防止克隆攻击)
  credential.counter = verification.authenticationInfo.newCounter;
  matchedUser.currentChallenge = undefined;

  return { verified: true, userId: matchedUser.id, username: matchedUser.username };
}

2.2 前端实现

// client/auth.ts — 前端 WebAuthn 调用封装
import {
  startRegistration,
  startAuthentication,
  browserSupportsWebAuthn,
} from '@simplewebauthn/browser';

// 检测浏览器支持
export function isPasskeySupported(): boolean {
  return browserSupportsWebAuthn();
}

// 注册 Passkey
export async function registerPasskey(username: string) {
  if (!isPasskeySupported()) {
    throw new Error('当前浏览器不支持 Passkeys,请升级浏览器');
  }

  // 1. 从服务端获取注册选项
  const options = await fetch('/api/webauthn/register/options', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ username }),
  }).then((r) => r.json());

  // 2. 调用浏览器 WebAuthn API,触发生物识别
  const registrationResponse = await startRegistration({ optionsJSON: options });

  // 3. 将响应发送到服务端验证
  const verification = await fetch('/api/webauthn/register/verify', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(registrationResponse),
  }).then((r) => r.json());

  return verification;
}

// 使用 Passkey 登录(无用户名模式)
export async function loginWithPasskey() {
  if (!isPasskeySupported()) {
    throw new Error('当前浏览器不支持 Passkeys');
  }

  // 1. 从服务端获取认证选项(不传 username,使用 Discoverable Credential)
  const options = await fetch('/api/webauthn/authenticate/options', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({}),
  }).then((r) => r.json());

  // 2. 调用浏览器 WebAuthn API
  const authResponse = await startAuthentication({ optionsJSON: options });

  // 3. 服务端验证
  const verification = await fetch('/api/webauthn/authenticate/verify', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(authResponse),
  }).then((r) => r.json());

  if (verification.verified) {
    // 存储 JWT 或设置 Cookie
    localStorage.setItem('token', verification.token);
  }
  return verification;
}

2.3 数据库设计

生产环境中,Passkey 凭据需要持久化存储。以下是推荐的 PostgreSQL 表设计:

-- Passkey 凭据表:存储用户的公钥凭据
CREATE TABLE webauthn_credentials (
    id              TEXT PRIMARY KEY,       -- Base64URL 编码的凭据 ID
    user_id         UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
    public_key       BYTEA NOT NULL,        -- CBOR 编码的公钥
    counter         BIGINT NOT NULL DEFAULT 0, -- 签名计数器(防克隆)
    transports      TEXT[],                 -- 支持的传输方式 ['internal', 'hybrid']
    aaguid          UUID,                   -- 认证器 AAGUID(用于识别设备类型)
    nickname        TEXT,                   -- 用户自定义名称,如「iPhone 16 Pro」
    created_at      TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    last_used_at    TIMESTAMPTZ,
    is_active       BOOLEAN NOT NULL DEFAULT TRUE
);

-- 索引:根据凭据 ID 快速查找用户
CREATE INDEX idx_webauthn_credentials_user_id ON webauthn_credentials(user_id);

-- Challenge 临时存储表(60秒过期自动清理)
CREATE TABLE webauthn_challenges (
    id              UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    user_id         UUID,                   -- 可为空(Discoverable Credential 认证时)
    challenge       TEXT NOT NULL,
    type            TEXT NOT NULL CHECK (type IN ('registration', 'authentication')),
    created_at      TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    expires_at      TIMESTAMPTZ NOT NULL DEFAULT NOW() + INTERVAL '60 seconds'
);

-- 自动清理过期 Challenge
CREATE INDEX idx_webauthn_challenges_expires ON webauthn_challenges(expires_at);

⚠️ **警告:**永远不要在数据库中存储私钥。私钥由用户设备的安全芯片(Secure Enclave / TPM)管理,服务端只存储公钥和元数据。这是 Passkeys 安全模型的根本——即使数据库被完全泄露,攻击者也无法冒充用户登录。

🔄 三、从密码迁移到 Passkeys:渐进式策略

直接要求所有用户切换到 Passkeys 是不现实的。以下是一个经过验证的四阶段迁移策略。

3.1 四阶段迁移路线图

阶段 策略 用户体验 安全等级 推荐周期
阶段 1 密码为主,Passkey 为可选二因素 无感知 ⭐⭐ 第 1-2 周
阶段 2 登录后提示绑定 Passkey 轻微干扰 ⭐⭐⭐ 第 3-4 周
阶段 3 新用户默认注册 Passkey,老用户强提示 部分摩擦 ⭐⭐⭐⭐ 第 5-8 周
阶段 4 Passkey 为主,密码降级为备用方案 无密码登录 ⭐⭐⭐⭐⭐ 长期

3.2 混合认证的后端实现

在迁移过程中,你需要同时支持密码和 Passkey 两种认证方式:

// server/hybrid-auth.ts — 支持密码 + Passkey 的混合认证
import { finishAuthentication as webauthnAuth } from './webauthn';
import { verifyPassword } from './password';
import { generateToken } from './jwt';

type AuthMethod = 'password' | 'passkey';

interface AuthResult {
  success: boolean;
  token?: string;
  userId?: string;
  authMethod?: AuthMethod;
  requiresPasskeySetup?: boolean;  // 提示用户绑定 Passkey
}

export async function authenticate(
  method: AuthMethod,
  payload: Record<string, unknown>
): Promise<AuthResult> {
  try {
    let userId: string;
    let username: string;

    if (method === 'passkey') {
      // Passkey 认证
      const result = await webauthnAuth(
        payload.response as AuthenticationResponseJSON
      );
      userId = result.userId;
      username = result.username;
    } else {
      // 密码认证
      const { email, password } = payload as { email: string; password: string };
      const result = await verifyPassword(email, password);
      if (!result.valid) {
        return { success: false };
      }
      userId = result.userId;
      username = result.username;
    }

    const token = generateToken({ userId, username, method });

    // 检查用户是否已绑定 Passkey
    const hasPasskey = await userHasPasskey(userId);

    return {
      success: true,
      token,
      userId,
      authMethod: method,
      // 密码登录但未绑定 Passkey 时,提示绑定
      requiresPasskeySetup: method === 'password' && !hasPasskey,
    };
  } catch (error) {
    console.error('认证失败:', error);
    return { success: false };
  }
}

async function userHasPasskey(userId: string): Promise<boolean> {
  // 查询数据库中是否存在该用户的活跃 Passkey 凭据
  const count = await db.webauthn_credentials.count({
    where: { user_id: userId, is_active: true },
  });
  return count > 0;
}

3.3 常见坑点与避坑指南

坑点 1:localhost 无法测试 Passkey

WebAuthn 要求 rpID 必须与当前域名完全一致。在 localhost 上,rpID 应设为 localhost,但某些浏览器对 localhost 的 Passkey 支持有限。推荐使用 mkcert 生成本地 HTTPS 证书,配合 *.localhost 域名测试。

坑点 2:iOS Safari 的用户验证行为差异

在 iOS 上,如果 userVerification: "required",Safari 会强制要求 Face ID / Touch ID 验证。但如果设为 "preferred",且设备上已有屏幕锁,Safari 可能跳过额外的生物识别弹窗。这对用户体验是好事,但你需要在安全策略上理解这个差异。

坑点 3:Counter 永远为 0 的同步 Passkey

Synced Passkey(如 iCloud Keychain 同步的 Passkey)的 counter 值始终为 0,因为云同步无法保证全局递增计数器的一致性。这意味着你不能依赖 counter 来检测克隆攻击——对于 Synced Passkey,这个机制已经失效。正确做法是关注 aaguid 来识别认证器类型。

💡 **提示:**如果你的目标用户群体以消费级设备为主(iPhone、Android),不要对 counter 做严格的递增校验。只在企业级硬件安全密钥(如 YubiKey)场景下才要求 counter 必须递增。

📊 四、Passkeys vs 传统方案全面对比

维度 密码 密码 + TOTP 二因素 Passkeys
钓鱼防护 ❌ 完全无防护 ⚠️ TOTP 可被实时钓鱼 ✅ 绑定域名,无法钓鱼
凭证泄露影响 ❌ 明文或哈希可破解 ⚠️ 密码泄露 + TOTP 有效期内可滥用 ✅ 服务端只有公钥,泄露无影响
用户体验 ⚠️ 需记忆/管理密码 ❌ 更繁琐 ✅ 生物识别一步完成
跨设备 ✅ 任意设备输入密码 ✅ 任意设备 + 验证器 App ✅ Synced Passkey 自动同步
开发复杂度 中高
用户教育成本 中等(首次需要引导)
恢复难度 忘记密码→邮件重置 换手机→迁移验证器 换设备→云同步自动恢复

⚡ **关键结论:**Passkeys 在安全性和用户体验上同时优于传统方案,唯一的代价是开发复杂度略高。对于面向消费者的应用,Passkeys 的钓鱼免疫特性是无可替代的安全提升。

💡 总结与工具推荐

Passkeys 的落地不是一蹴而就的事,但渐进式迁移策略可以让整个过程对用户几乎透明。核心建议:

  • 立即开始:先在新项目中集成 Passkey 注册,作为可选功能
  • 使用成熟库:服务端用 @simplewebauthn/server,浏览器端用 @simplewebauthn/browser,避免手写 CBOR 解析
  • 支持 Synced PasskeyresidentKey: "preferred" 是消费级应用的正确选择
  • 不要强制要求 Passkey:保留密码作为备用方案,至少在过渡期内
  • 不要在服务端存储任何私钥相关数据:这违反了 Passkeys 的安全模型
  • ⚠️ 注意 WebAuthn 的浏览器兼容性:Safari 16+、Chrome 108+、Firefox 122+ 支持完整特性

相关工具推荐:

📚 相关文章