2026 年,密码泄露事件仍在以每年 30% 的速度增长——RockYou2024 泄露了 100 亿条密码,Verizon 数据泄露报告指出 81% 的黑客攻击利用了弱密码或泄露密码。Passkeys(通行密钥)作为 FIDO2 联盟推动的无密码认证标准,已经被 Apple、Google、Microsoft 三大平台原生支持,覆盖全球超过 40 亿台设备。对于开发者而言,理解并实现 Passkeys 不再是「锦上添花」,而是认证系统的必选项。
本文不会停留在「Passkeys 是什么」的科普层面,而是直接深入 WebAuthn API 的技术细节,手把手实现完整的注册和认证流程,并与传统 JWT + 密码方案做详细对比,帮你判断何时切换、如何切换。
🔐 一、Passkeys 的技术原理与架构
1.1 公钥密码学在认证中的应用
Passkeys 的核心是非对称加密(Asymmetric Cryptography)。与传统密码方案不同,用户的认证凭证不是存储在服务器上的密码哈希,而是一对密钥:
- 私钥(Private Key):存储在用户设备的安全芯片中(如 Apple Secure Enclave、Google Titan M),永远不会离开设备
- 公钥(Public Key):注册时发送到服务器存储,用于验证签名
认证流程本质上是一个**挑战-响应(Challenge-Response)**过程:
- 服务器生成一个随机挑战(Challenge)发送给客户端
- 客户端用私钥对挑战进行签名
- 服务器用公钥验证签名
📌 **记住:**私钥永远不会传输到网络上,即使服务器被攻破,攻击者也无法获取用户的私钥——这从根本上消除了密码泄露的风险。
1.2 Passkeys vs 传统密码 vs WebAuthn 旧版
很多开发者容易混淆这几个概念,先理清它们的关系:
| 特性 | 传统密码 | WebAuthn 安全密钥 | Passkeys |
|---|---|---|---|
| 用户记忆 | 需要记忆密码 | 不需要 | 不需要 |
| 私钥存储 | N/A(无密钥对) | 物理安全密钥 | 设备安全芯片 + 云同步 |
| 多设备同步 | 自动(密码共享) | ❌ 不支持 | ✅ 云同步 |
| 钓鱼攻击防护 | ❌ 弱 | ✅ 强(绑定 Origin) | ✅ 强(绑定 Origin) |
| 中间人攻击防护 | ❌ 弱 | ✅ 强 | ✅ 强 |
| 用户体验 | 差(记忆负担) | 中(需插拔设备) | 优(生物识别) |
| 设备丢失恢复 | 重置密码 | 需备用密钥 | 云同步自动恢复 |
| 实现复杂度 | 低 | 高 | 中 |
⚡ **关键结论:**Passkeys 本质上是「可云同步的 WebAuthn 凭证」——它继承了 WebAuthn 的安全性,同时解决了多设备同步的痛点。
1.3 平台认证器 vs 漫游认证器
WebAuthn 定义了两种认证器(Authenticator)类型:
平台认证器(Platform Authenticator):
- 内置在设备中:Touch ID、Face ID、Windows Hello、Android 生物识别
- 用户体验最好:指纹或面部识别即可完成认证
- Passkeys 默认使用平台认证器
漫游认证器(Roaming Authenticator):
- 外接设备:YubiKey、Google Titan Key
- 通过 USB、NFC、BLE 连接
- 适合高安全场景的二次认证
🚀 二、完整实现:从注册到认证
2.1 后端实现(Node.js + @simplewebauthn/server)
首先安装依赖:
npm install @simplewebauthn/server @simplewebauthn/browser
以下是一个完整的 Passkey 注册和认证后端实现。@simplewebauthn 是目前最成熟的 WebAuthn 库,封装了复杂的 CBOR 编解码和证书验证逻辑:
// server.js — Passkey 注册与认证完整后端
import {
generateRegistrationOptions,
verifyRegistrationResponse,
generateAuthenticationOptions,
verifyAuthenticationResponse,
} from '@simplewebauthn/server';
import express from 'express';
const app = express();
app.use(express.json());
// ⚠️ 生产环境必须替换为真实域名
const rpName = 'My App';
const rpID = 'localhost';
const expectedOrigin = `http://${rpID}:3000`;
// 模拟数据库存储(生产环境用 PostgreSQL/Redis)
const userDB = {}; // { userId: { id, username, passkeys: [] } }
const challengeDB = {}; // { userId: challenge }
// ========== 注册流程 ==========
// 第一步:生成注册选项
app.post('/api/register/start', async (req, res) => {
const { username } = req.body;
// 查找或创建用户
let user = Object.values(userDB).find(u => u.username === username);
if (!user) {
const userId = `user_${Date.now()}`;
user = { id: userId, username, passkeys: [] };
userDB[userId] = user;
}
const options = await generateRegistrationOptions({
rpName,
rpID,
userID: Buffer.from(user.id),
userName: user.username,
userDisplayName: user.username,
// 排除已注册的凭证,防止重复注册
excludeCredentials: user.passkeys.map(pk => ({
id: pk.credentialID,
type: 'public-key',
})),
authenticatorSelection: {
// 要求平台认证器(Touch ID / Face ID)
authenticatorAttachment: 'platform',
// 允许无密码登录(Passkey 模式)
residentKey: 'required',
userVerification: 'required',
},
});
// 临时存储 challenge,后续验证用
challengeDB[user.id] = options.challenge;
res.json(options);
});
// 第二步:验证注册响应
app.post('/api/register/verify', async (req, res) => {
const { userId, credential } = req.body;
const expectedChallenge = challengeDB[userId];
try {
const verification = await verifyRegistrationResponse({
response: credential,
expectedChallenge,
expectedOrigin,
expectedRPID: rpID,
});
if (verification.verified && verification.registrationInfo) {
const { credentialPublicKey, credentialID, counter } =
verification.registrationInfo;
const user = userDB[userId];
user.passkeys.push({
credentialID: Buffer.from(credentialID).toString('base64url'),
credentialPublicKey: Buffer.from(credentialPublicKey).toString('base64url'),
counter,
});
}
delete challengeDB[userId];
res.json({ verified: verification.verified });
} catch (error) {
res.status(400).json({ error: error.message });
}
});
// ========== 认证流程 ==========
// 第三步:生成认证选项
app.post('/api/login/start', async (req, res) => {
const { username } = req.body;
const user = Object.values(userDB).find(u => u.username === username);
const options = await generateAuthenticationOptions({
rpID,
userVerification: 'required',
// 允许所有已注册的凭证
allowCredentials: user?.passkeys.map(pk => ({
id: pk.credentialID,
type: 'public-key',
})) ?? [],
});
if (user) challengeDB[user.id] = options.challenge;
res.json(options);
});
// 第四步:验证认证响应
app.post('/api/login/verify', async (req, res) => {
const { username, credential } = req.body;
const user = Object.values(userDB).find(u => u.username === username);
if (!user) return res.status(404).json({ error: 'User not found' });
const passkey = user.passkeys.find(
pk => pk.credentialID === credential.id
);
if (!passkey) return res.status(404).json({ error: 'Passkey not found' });
try {
const verification = await verifyAuthenticationResponse({
response: credential,
expectedChallenge: challengeDB[user.id],
expectedOrigin,
expectedRPID: rpID,
authenticator: {
credentialPublicKey: Buffer.from(passkey.credentialPublicKey, 'base64url'),
credentialID: Buffer.from(passkey.credentialID, 'base64url'),
counter: passkey.counter,
},
});
// 更新 counter 防重放攻击
if (verification.verified) {
passkey.counter = verification.authenticationInfo.newCounter;
}
delete challengeDB[user.id];
res.json({ verified: verification.verified });
} catch (error) {
res.status(400).json({ error: error.message });
}
});
app.listen(3000, () => console.log('Server running on port 3000'));
2.2 前端实现(@simplewebauthn/browser)
前端代码使用 @simplewebauthn/browser 库,它封装了 navigator.credentials.create() 和 navigator.credentials.get() 的复杂参数构造:
// auth.js — 前端 Passkey 注册与认证
import {
startRegistration,
startAuthentication,
browserSupportsWebAuthn,
} from '@simplewebauthn/browser';
// 检查浏览器是否支持 WebAuthn
if (!browserSupportsWebAuthn()) {
alert('您的浏览器不支持 Passkeys,请升级到最新版本');
}
// ========== 注册 Passkey ==========
async function registerPasskey(username) {
try {
// 第一步:从服务器获取注册选项
const optionsResp = await fetch('/api/register/start', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username }),
});
const options = await optionsResp.json();
// 第二步:调用浏览器 WebAuthn API 创建凭证
// 这一步会弹出系统级的生物识别对话框
const credential = await startRegistration({ optionsJSON: options });
// 第三步:将凭证发送到服务器验证
const verifyResp = await fetch('/api/register/verify', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
userId: options.user.id,
credential,
}),
});
const result = await verifyResp.json();
if (result.verified) {
console.log('✅ Passkey 注册成功!');
}
return result;
} catch (error) {
// 常见错误:用户取消、设备不支持、超时
if (error.name === 'NotAllowedError') {
console.log('用户取消了注册');
} else {
console.error('注册失败:', error);
}
throw error;
}
}
// ========== Passkey 认证 ==========
async function loginWithPasskey(username) {
try {
// 第一步:获取认证选项
const optionsResp = await fetch('/api/login/start', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username }),
});
const options = await optionsResp.json();
// 第二步:调用浏览器 WebAuthn API 进行认证
// 系统弹出生物识别验证(Touch ID / Face ID / 指纹)
const credential = await startAuthentication({ optionsJSON: options });
// 第三步:发送到服务器验证签名
const verifyResp = await fetch('/api/login/verify', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, credential }),
});
const result = await verifyResp.json();
if (result.verified) {
console.log('✅ 认证成功!');
}
return result;
} catch (error) {
console.error('认证失败:', error);
throw error;
}
}
// 导出供页面使用
window.registerPasskey = registerPasskey;
window.loginWithPasskey = loginWithPasskey;
⚠️ **警告:**WebAuthn API 只能在安全上下文(Secure Context)中使用——即 HTTPS 或
localhost。在 HTTP 环境下,navigator.credentials会返回undefined。开发环境务必使用localhost或配置自签名证书。
2.3 与现有系统的混合认证策略
生产环境中,不建议一刀切地完全替换密码。更务实的方案是渐进式迁移:
// hybrid-auth.js — 混合认证策略
async function authenticate(username, method, payload) {
switch (method) {
case 'passkey':
return loginWithPasskey(username);
case 'password':
// 传统密码登录
const resp = await fetch('/api/login/password', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, password: payload.password }),
});
return resp.json();
case 'password+mfa':
// 密码 + Passkey 作为二次验证
const passwordResult = await authenticate(username, 'password', payload);
if (!passwordResult.verified) return passwordResult;
// 密码通过后,用 Passkey 做二次验证
return loginWithPasskey(username);
}
}
// 渐进式引导用户注册 Passkey
async function suggestPasskeyAfterLogin(userId) {
const hasPasskey = await checkUserHasPasskey(userId);
if (!hasPasskey) {
showBanner({
title: '启用 Passkey 无密码登录',
description: '使用指纹或面部识别,下次登录更快更安全',
action: () => registerPasskey(userId),
});
}
}
📊 三、方案对比与生产实战
3.1 Passkeys vs JWT + 密码:全维度对比
| 维度 | JWT + 密码 | Passkeys + WebAuthn |
|---|---|---|
| 用户注册步骤 | 输入密码 + 确认密码 | 生物识别一次确认 |
| 平均登录耗时 | 15-30 秒(输入密码) | 2-5 秒(指纹/面部) |
| 密码重置流程 | 邮箱/短信验证 | 不需要(无私密密码) |
| 服务端存储风险 | 密码哈希可能被破解 | 仅存储公钥,无风险 |
| 钓鱼防护 | ❌ 无 | ✅ Origin 绑定 |
| 中间人攻击防护 | ❌ 无 | ✅ 有 |
| 实现复杂度 | ⭐ 低 | ⭐⭐⭐ 中高 |
| 兼容性 | 100% | 95%+(2026 年数据) |
| 用户教育成本 | 无 | 有一定成本 |
⚡ **关键结论:**Passkeys 在安全性上碾压传统密码方案,但在实现复杂度和用户教育上需要额外投入。建议采用「密码 + Passkey 并存」的渐进式策略。
3.2 生产环境的常见坑与避坑指南
坑 1:Origin 验证不正确导致认证失败
WebAuthn 严格绑定 Origin(协议 + 域名 + 端口)。开发时用 localhost:3000,部署到 example.com 后所有已注册的 Passkey 都会失效。
💡 **提示:**开发环境和生产环境的 Origin 不同,Passkey 不可迁移。建议在开发阶段就使用与生产一致的域名(通过 hosts 文件映射),或做好用户引导重新注册的准备。
坑 2:CBOR 编解码格式问题
WebAuthn 使用 CBOR(Concise Binary Object Representation)编码,不同语言的 CBOR 库实现存在微妙差异。特别是 credentialPublicKey 的编码格式——有些库输出 DER,有些输出 COSE。
// ❌ 错误:手动解析 CBOR 容易出错
const publicKey = cbor.decodeFirstSync(credentialPublicKey);
// ✅ 正确:使用 @simplewebauthn 自动处理
import { verifyRegistrationResponse } from '@simplewebauthn/server';
// 库内部处理 CBOR 编解码,无需手动操作
const verification = await verifyRegistrationResponse({
response: credential,
expectedChallenge,
expectedOrigin,
expectedRPID: rpID,
});
坑 3:counter 更新不及时导致重放攻击风险
WebAuthn 的 counter 字段用于防止凭证重放攻击。每次认证成功后,服务端必须更新存储的 counter 值:
// ⚠️ 警告:不更新 counter 会导致重放攻击风险
// counter 检查:客户端返回的 counter 必须大于服务端存储的值
if (verification.verified) {
passkey.counter = verification.authenticationInfo.newCounter;
await savePasskeyToDB(passkey); // 必须持久化
}
坑 4:iOS Safari 的特殊行为
iOS Safari 在某些场景下会将 Passkey 视为「安全密钥」而非「平台凭证」,导致弹出「使用附近的安全密钥」的提示。解决方案是在 authenticatorSelection 中明确指定 authenticatorAttachment: 'platform'。
3.3 多平台 Passkey 同步策略
Passkeys 的最大优势之一是跨设备同步,但不同平台的同步机制不同:
| 平台 | 同步服务 | 覆盖设备 | 特殊限制 |
|---|---|---|---|
| Apple | iCloud Keychain | iPhone/iPad/Mac/Apple TV | 需开启双重认证 |
| Google Password Manager | Android/Chrome/ChromeOS | 需登录 Google 账号 | |
| Microsoft | Windows Hello | Windows 10/11 | 需 Microsoft 账号 |
| 1Password | 1Password | 全平台 | 需付费订阅 |
| Bitwarden | Bitwarden | 全平台 | 开源免费 |
💡 **提示:**建议在用户注册 Passkey 时,引导用户确认其设备已登录 iCloud/Google 账号且开启了密码同步。否则设备丢失后 Passkey 将无法恢复。
3.4 安全审计清单
部署 Passkeys 前,请逐项检查以下安全配置:
- ✅ HTTPS 强制启用(WebAuthn 的硬性要求)
- ✅ RP ID 设置为实际域名(不要用
localhost) - ✅ Challenge 使用密码学安全随机数生成(至少 16 字节)
- ✅ Challenge 设置合理的过期时间(建议 5 分钟)
- ✅ Counter 值持久化并正确更新
- ✅ Origin 验证包含协议、域名和端口
- ✅ 用户验证策略(User Verification)设置为
required - ✅ 凭证存储使用 base64url 编码(不是 base64)
- ❌ 不要在 URL 中传递 challenge 或 credential
- ❌ 不要跳过 certificate chain 验证(生产环境)
💡 总结与行动建议
Passkeys 不是未来的技术概念,而是已经落地的生产级认证方案。2026 年,全球 Passkey 注册量已超过 15 亿,主流框架(Next.js、Nuxt、Express)都有成熟的 WebAuthn 库支持。
行动建议:
- 立即开始:在现有系统中添加 Passkey 作为可选登录方式,不要等用户要求
- 渐进迁移:保持密码登录,用 Banner 引导用户升级到 Passkey
- 优先移动端:移动端的生物识别体验最好,是推广 Passkey 的最佳入口
- 监控指标:跟踪 Passkey 注册率、认证成功率、设备覆盖率
相关工具推荐:
- 🔧 SimpleWebAuthn — 最成熟的 WebAuthn 库,前后端都有
- 🔧 FIDO2 MDS Explorer — 浏览 FIDO 认证器元数据
- 🔧 WebAuthn.io — 在线测试 WebAuthn 注册和认证流程
- 🔧 jsjson.com JSON 工具 — 调试 WebAuthn 的 CBOR/JSON 响应数据