Passkeys 实战指南:用 WebAuthn 彻底告别密码登录

深入解析 Passkeys (WebAuthn) 的工作原理,手把手教你实现无密码登录,包含完整代码示例、数据库设计、安全对比和生产部署最佳实践。

安全与密码 2026-06-07 12 分钟

2025 年,Google 宣布超过 4 亿个 Passkeys 账户已启用,GitHub、Apple、Microsoft 全面支持 Passkeys 登录。Passkeys(通行密钥) 正在以惊人的速度取代传统密码——它不仅彻底消除了钓鱼攻击的风险,还让登录体验从「输入密码」变成了「刷一下指纹」。如果你还在用 bcrypt + JWT 的老方案做认证,这篇文章会告诉你为什么要迁移、以及如何迁移。

🔐 一、Passkeys 到底是什么?为什么比密码安全?

1.1 核心原理:非对称加密 + 挑战-响应

Passkeys 基于 WebAuthn(Web Authentication) 标准,它是 FIDO2 协议的核心组成部分。与传统密码认证的根本区别在于:

  • 密码认证:用户把「秘密」(密码)发送给服务器 → 服务器存储密码哈希 → 任何一方泄露都危险
  • Passkeys 认证:用户持有私钥(永不离开设备),服务器只存储公钥 → 即使数据库泄露,攻击者也无法伪造登录

认证流程分为两步:

注册阶段(Registration):服务器生成一个随机挑战(Challenge),浏览器调用 navigator.credentials.create(),设备生成密钥对(私钥存在安全芯片中,公钥发送给服务器存储)。

认证阶段(Authentication):服务器再次生成挑战,浏览器调用 navigator.credentials.get(),设备用私钥签名挑战,服务器用公钥验证签名。

📌 记住: 私钥永远不会离开用户的设备(存储在 Secure Enclave / TPM 芯片中),这是 Passkeys 安全性的根基。

1.2 Passkeys vs 传统认证方案对比

维度 密码 + Session 密码 + JWT OAuth 2.0 Passkeys
钓鱼攻击风险 ⚠️ 高 ⚠️ 高 ⚠️ 中 ✅ 极低
暴力破解风险 ⚠️ 高 ⚠️ 高 ✅ 低 ✅ 无
密码泄露风险 ⚠️ 存在 ⚠️ 存在 ✅ 无 ✅ 无
用户体验 ❌ 差 ❌ 差 ✅ 好 ✅ 最佳
实现复杂度 ✅ 低 ✅ 低 ⚠️ 中 ⚠️ 中
跨设备同步 ✅ 无需 ✅ 无需 ✅ 无需 ✅ 云同步
离线可用 ❌ 不可 ⚠️ JWT 可 ❌ 不可 ✅ 可

⚠️ 警告: Passkeys 并非万能。它依赖设备的生物识别或 PIN,如果用户的设备丢失且没有云同步,可能会被锁定。因此建议保留一个备用认证方式(如邮箱验证码)。

🚀 二、从零实现 Passkeys 认证(Node.js + 浏览器端)

我们使用 @simplewebauthn 库,它是目前 WebAuthn 生态中最成熟的开源实现,API 设计清晰,TypeScript 支持良好。

2.1 项目依赖安装

# 服务端
npm install @simplewebauthn/server @simplewebauthn/types

# 浏览器端
npm install @simplewebauthn/browser

2.2 数据库设计

在实现之前,需要设计存储 Passkeys 的数据库表。核心要保存公钥(Credential)、签名计数器(Counter)和用户关联:

-- passkeys 表设计
CREATE TABLE passkeys (
    id              VARCHAR(36) PRIMARY KEY,
    user_id         VARCHAR(36) NOT NULL,
    credential_id   TEXT NOT NULL UNIQUE,        -- Base64URL 编码的凭证 ID
    credential_public_key BLOB NOT NULL,         -- 公钥字节
    counter         BIGINT NOT NULL DEFAULT 0,   -- 签名计数器(防重放)
    transports      VARCHAR(255),                -- 传输方式:usb,ble,nfc,internal
    device_name     VARCHAR(255),                -- 用户自定义设备名称
    created_at      TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    last_used_at    TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    FOREIGN KEY (user_id) REFERENCES users(id)
);

CREATE INDEX idx_passkeys_user_id ON passkeys(user_id);
CREATE INDEX idx_passkeys_credential_id ON passkeys(credential_id);

💡 提示: counter 字段用于防止重放攻击。每次认证成功后,服务端需要检查 counter 是否大于存储值,然后更新。如果 counter 没有递增,说明可能存在克隆攻击。

2.3 服务端实现(Node.js + Express)

服务端需要提供两个 API:/register/generate-options 生成注册挑战,/register/verify 验证注册响应。

// server/passkeys.js
import {
  generateRegistrationOptions,
  verifyRegistrationResponse,
  generateAuthenticationOptions,
  verifyAuthenticationResponse,
} from '@simplewebauthn/server';
import { isoBase64URL } from '@simplewebauthn/server/helpers';

// 配置
const rpName = 'jsjson.com';
const rpID = 'jsjson.com';           // 必须与前端域名一致
const origin = `https://${rpID}`;

// ============ 注册流程 ============

// 1. 生成注册选项
async function handleGenerateRegistrationOptions(req, res) {
  const { userId, username, displayName } = req.body;

  // 查询用户已有的 Passkeys(用于排除重复注册)
  const existingPasskeys = await db.query(
    'SELECT credential_id FROM passkeys WHERE user_id = ?', [userId]
  );

  const options = await generateRegistrationOptions({
    rpName,
    rpID,
    userID: Buffer.from(userId),
    userName: username,
    userDisplayName: displayName,
    // 排除已注册的凭证,避免重复注册同一设备
    excludeCredentials: existingPasskeys.map(p => ({
      id: p.credential_id,
      type: 'public-key',
    })),
    authenticatorSelection: {
      // platform = 设备内置(指纹/面部),cross-platform = 外接安全密钥
      authenticatorAttachment: 'platform',
      // 不要求 residentKey,兼容性更好
      residentKey: 'preferred',
      userVerification: 'required',  // 要求用户验证(生物识别/PIN)
    },
  });

  // 将 challenge 存入 session,后续验证使用
  req.session.currentChallenge = options.challenge;

  return res.json(options);
}

// 2. 验证注册响应
async function handleVerifyRegistration(req, res) {
  const { userId } = req.body;
  const expectedChallenge = req.session.currentChallenge;

  if (!expectedChallenge) {
    return res.status(400).json({ error: '没有待验证的注册请求' });
  }

  const verification = await verifyRegistrationResponse({
    response: req.body.credential,
    expectedChallenge,
    expectedOrigin: origin,
    expectedRPID: rpID,
  });

  if (!verification.verified || !verification.registrationInfo) {
    return res.status(400).json({ error: '注册验证失败' });
  }

  const { credential, credentialDeviceType, credentialBackedUp } =
    verification.registrationInfo;

  // 保存 Passkey 到数据库
  await db.query(
    `INSERT INTO passkeys (id, user_id, credential_id, credential_public_key, counter, device_name)
     VALUES (?, ?, ?, ?, ?, ?)`,
    [
      generateId(),
      userId,
      isoBase64URL.fromBuffer(credential.id),
      credential.publicKey,  // Buffer
      credential.counter,
      req.body.deviceName || '未命名设备',
    ]
  );

  req.session.currentChallenge = null;
  return res.json({ verified: true });
}

2.4 认证流程实现

// server/passkeys-auth.js

// 3. 生成认证选项
async function handleGenerateAuthenticationOptions(req, res) {
  const { username } = req.body;

  // 根据用户名查找关联的 Passkeys
  const user = await db.query('SELECT id FROM users WHERE username = ?', [username]);
  if (!user) {
    // 即使用户不存在,也返回选项(不泄露用户是否存在)
    // 但不传 allowCredentials,让浏览器提示选择任意 Passkey
  }

  const passkeys = user
    ? await db.query('SELECT credential_id, transports FROM passkeys WHERE user_id = ?', [user.id])
    : [];

  const options = await generateAuthenticationOptions({
    rpID,
    userVerification: 'required',
    allowCredentials: passkeys.map(p => ({
      id: p.credential_id,
      type: 'public-key',
      transports: p.transports ? JSON.parse(p.transports) : undefined,
    })),
  });

  req.session.currentChallenge = options.challenge;
  return res.json(options);
}

// 4. 验证认证响应
async function handleVerifyAuthentication(req, res) {
  const expectedChallenge = req.session.currentChallenge;
  if (!expectedChallenge) {
    return res.status(400).json({ error: '没有待验证的认证请求' });
  }

  // 根据 credential_id 查找对应的 Passkey
  const credentialID = req.body.credential.id;
  const passkey = await db.query(
    'SELECT * FROM passkeys WHERE credential_id = ?', [credentialID]
  );

  if (!passkey) {
    return res.status(400).json({ error: '未找到对应的 Passkey' });
  }

  const verification = await verifyAuthenticationResponse({
    response: req.body.credential,
    expectedChallenge,
    expectedOrigin: origin,
    expectedRPID: rpID,
    credential: {
      id: passkey.credential_id,
      publicKey: passkey.credential_public_key,
      counter: passkey.counter,
    },
  });

  if (!verification.verified) {
    return res.status(400).json({ error: '认证验证失败' });
  }

  // 更新 counter(防重放攻击)
  await db.query(
    'UPDATE passkeys SET counter = ?, last_used_at = NOW() WHERE id = ?',
    [verification.authenticationInfo.newCounter, passkey.id]
  );

  req.session.currentChallenge = null;

  // 签发 Session 或 JWT
  const token = generateSessionToken(passkey.user_id);
  return res.json({ verified: true, token });
}

2.5 浏览器端实现

// client/passkeys.js
import {
  startRegistration,
  startAuthentication,
  browserSupportsWebAuthn,
} from '@simplewebauthn/browser';

// 检查浏览器支持
if (!browserSupportsWebAuthn()) {
  console.warn('当前浏览器不支持 WebAuthn');
}

// ============ 注册 Passkey ============
async function registerPasskey(username, displayName) {
  try {
    // 1. 从服务端获取注册选项
    const optionsRes = await fetch('/api/register/generate-options', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ userId: getCurrentUserId(), username, displayName }),
    });
    const options = await optionsRes.json();

    // 2. 调用浏览器 WebAuthn API(弹出生物识别提示)
    const credential = await startRegistration(options);

    // 3. 将响应发送给服务端验证
    const verifyRes = await fetch('/api/register/verify', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({
        userId: getCurrentUserId(),
        credential,
        deviceName: navigator.userAgent.includes('iPhone') ? 'iPhone' : 'PC',
      }),
    });

    const result = await verifyRes.json();
    if (result.verified) {
      alert('✅ Passkey 注册成功!下次登录可以使用生物识别了。');
    }
  } catch (err) {
    if (err.name === 'NotAllowedError') {
      console.log('用户取消了 Passkey 注册');
    } else {
      console.error('注册失败:', err);
    }
  }
}

// ============ 使用 Passkey 登录 ============
async function loginWithPasskey(username) {
  try {
    // 1. 获取认证选项
    const optionsRes = await fetch('/api/auth/generate-options', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ username }),
    });
    const options = await optionsRes.json();

    // 2. 调用浏览器 WebAuthn API
    const credential = await startAuthentication(options);

    // 3. 服务端验证
    const verifyRes = await fetch('/api/auth/verify', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ credential }),
    });

    const result = await verifyRes.json();
    if (result.verified) {
      localStorage.setItem('token', result.token);
      window.location.href = '/dashboard';
    }
  } catch (err) {
    if (err.name === 'NotAllowedError') {
      console.log('用户取消了认证或超时');
    } else {
      console.error('认证失败:', err);
    }
  }
}

⚠️ 三、生产环境的坑点与避坑指南

3.1 必须使用 HTTPS

WebAuthn API 在非 HTTPS 环境下会被浏览器直接拒绝(localhost 除外)。这是安全设计的硬性要求。

⚠️ 警告: 开发环境使用 localhost 可以跳过 HTTPS 检查,但一旦部署到线上,没有 SSL 证书就无法使用 Passkeys。请确保在部署前配置好 HTTPS。

3.2 RP ID 与域名的关系

rpID(Relying Party Identifier)必须与当前页面的域名匹配。常见错误:

  • rpID: 'www.jsjson.com' 但用户访问的是 jsjson.com → 失败
  • rpID: 'jsjson.com' → 同时匹配 jsjson.comwww.jsjson.com(子域名兼容)
  • rpID: 'https://jsjson.com' → 不能带协议前缀
// ❌ 错误写法
const rpID = 'https://www.jsjson.com';

// ✅ 正确写法:只用域名,不带协议和端口
const rpID = 'jsjson.com';

3.3 跨设备同步与多设备策略

Passkeys 现在支持跨设备同步:

  • Apple:通过 iCloud Keychain 自动同步到所有 Apple 设备
  • Google:通过 Google Password Manager 同步到 Android 和 Chrome
  • Microsoft:通过 Windows Hello + Microsoft Authenticator

但不同生态之间不互通。因此强烈建议用户注册多个 Passkeys(手机 + 电脑 + 安全密钥),避免单点故障。

// 在注册页面提示用户
function renderPasskeyManagement(passkeys) {
  const hasPhone = passkeys.some(p => p.deviceName.includes('Phone'));
  const hasPC = passkeys.some(p => p.deviceName.includes('PC'));

  let warnings = [];
  if (passkeys.length === 1) {
    warnings.push('⚠️ 你只有 1 个 Passkey,建议添加备用设备以防丢失。');
  }
  if (!hasPhone && !hasPC) {
    warnings.push('📱 建议同时在手机和电脑上注册 Passkey。');
  }

  return warnings;
}

3.4 Challenge 必须一次性使用

Challenge 是防重放攻击的核心。必须遵守:

  • ✅ 每次请求生成新的 Challenge
  • ✅ 验证后立即销毁(从 Session 中删除)
  • ✅ 设置过期时间(建议 5 分钟)
  • ❌ 不要重复使用同一个 Challenge
  • ❌ 不要把 Challenge 存在 localStorage(客户端可篡改)
// challenge 管理最佳实践
const crypto = require('crypto');

function generateChallenge() {
  return crypto.randomBytes(32).toString('base64url');
}

// 在 Session 中存储,设置过期
req.session.currentChallenge = {
  value: challenge,
  createdAt: Date.now(),
  expiresAt: Date.now() + 5 * 60 * 1000, // 5 分钟过期
};

3.5 Counter 异常处理

如果认证时收到的 counter 小于等于存储值,说明可能存在设备克隆攻击。处理策略:

// counter 验证逻辑
if (verification.authenticationInfo.newCounter <= passkey.counter) {
  // counter 没有递增,可能的克隆攻击
  console.warn(`[SECURITY] Passkey ${passkey.id} counter 异常: ` +
    `expected > ${passkey.counter}, got ${verification.authenticationInfo.newCounter}`);

  // 可选:标记该 Passkey 为可疑,要求用户重新注册
  await db.query('UPDATE passkeys SET suspicious = true WHERE id = ?', [passkey.id]);

  // 仍然允许登录(某些设备可能不实现 counter 递增)
  // 但记录告警
}

💡 四、最佳实践总结与生产部署清单

4.1 完整的迁移策略

从密码迁移到 Passkeys 不应该是一步到位的,而是渐进式的:

  1. Phase 1:在现有密码登录基础上,添加「注册 Passkey」入口
  2. Phase 2:用户注册 Passkey 后,提供 Passkey 优先的登录界面
  3. Phase 3:统计 Passkey 使用率,对已注册 Passkey 的用户提示关闭密码
  4. Phase 4:新用户默认引导注册 Passkeys,密码作为可选

4.2 生产部署 Checklist

  • ✅ HTTPS 必须开启(证书有效,非自签名)
  • ✅ rpID 与实际域名一致(不含协议和端口)
  • ✅ Challenge 存储在服务端 Session,5 分钟过期
  • ✅ Counter 每次认证后更新并校验
  • ✅ 允许用户注册多个 Passkeys(至少建议 2 个)
  • ✅ 保留备用认证方式(邮箱验证码/短信)
  • ✅ 记录 Passkey 的创建时间、最后使用时间、设备信息
  • ✅ 前端检测 browserSupportsWebAuthn() 并优雅降级

4.3 各平台兼容性

平台 浏览器 Passkey 创建 Passkey 认证 跨设备同步
iOS 16+ Safari iCloud
Android 9+ Chrome Google
macOS Ventura+ Safari iCloud
Windows 10+ Chrome/Edge Microsoft
Linux Chrome ❌ 无云同步

💡 提示: Linux 用户目前没有原生的 Passkey 云同步方案,通常需要使用硬件安全密钥(如 YubiKey)。如果目标用户群体有大量 Linux 用户,务必提供备用登录方式。

4.4 相关工具推荐

总结

Passkeys 不是一个「即将到来」的技术——它已经是现在时。Google、Apple、Microsoft 三大平台全面支持,超过 10 亿设备已具备 Passkeys 能力。对于开发者来说,现在集成 Passkeys 的成本已经很低(@simplewebauthn 把复杂的 WebAuthn 协议封装成了简洁的 API),但带来的安全提升和用户体验改善是巨大的。

关键结论: 如果你在 2026 年还在用纯密码认证,至少应该给用户一个注册 Passkeys 的选项。这不是「锦上添花」,而是安全基础设施的升级——就像当年从 HTTP 迁移到 HTTPS 一样,Passkeys 将成为认证的标准答案。

📚 相关文章