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.com和www.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 不应该是一步到位的,而是渐进式的:
- Phase 1:在现有密码登录基础上,添加「注册 Passkey」入口
- Phase 2:用户注册 Passkey 后,提供 Passkey 优先的登录界面
- Phase 3:统计 Passkey 使用率,对已注册 Passkey 的用户提示关闭密码
- 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 | ✅ | ✅ | |
| macOS Ventura+ | Safari | ✅ | ✅ | iCloud |
| Windows 10+ | Chrome/Edge | ✅ | ✅ | Microsoft |
| Linux | Chrome | ✅ | ✅ | ❌ 无云同步 |
💡 提示: Linux 用户目前没有原生的 Passkey 云同步方案,通常需要使用硬件安全密钥(如 YubiKey)。如果目标用户群体有大量 Linux 用户,务必提供备用登录方式。
4.4 相关工具推荐
- 🔧 SimpleWebAuthn:本文使用的库,WebAuthn 生态最成熟的开源方案
- 🔧 Corbado:Passkeys 即服务(PaaS),适合快速集成
- 🔧 passkeys.io:在线测试 Passkeys 兼容性
- 🔧 FIDO Alliance 测试工具:合规性测试
总结
Passkeys 不是一个「即将到来」的技术——它已经是现在时。Google、Apple、Microsoft 三大平台全面支持,超过 10 亿设备已具备 Passkeys 能力。对于开发者来说,现在集成 Passkeys 的成本已经很低(@simplewebauthn 把复杂的 WebAuthn 协议封装成了简洁的 API),但带来的安全提升和用户体验改善是巨大的。
⚡ 关键结论: 如果你在 2026 年还在用纯密码认证,至少应该给用户一个注册 Passkeys 的选项。这不是「锦上添花」,而是安全基础设施的升级——就像当年从 HTTP 迁移到 HTTPS 一样,Passkeys 将成为认证的标准答案。