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):
- 客户端向服务端请求注册挑战值
- 服务端生成随机 Challenge,返回
PublicKeyCredentialCreationOptions - 浏览器调用
navigator.credentials.create(),弹出生物识别验证 - 设备生成密钥对,返回公钥 + 签名的 Challenge
- 服务端验证签名并存储公钥
认证阶段(Authentication Ceremony):
- 客户端向服务端请求认证挑战值
- 服务端生成随机 Challenge,返回
PublicKeyCredentialRequestOptions - 浏览器调用
navigator.credentials.get(),用户完成生物识别 - 设备用私钥签名 Challenge,返回签名
- 服务端用存储的公钥验证签名,签发 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 Passkey:
residentKey: "preferred"是消费级应用的正确选择 - ❌ 不要强制要求 Passkey:保留密码作为备用方案,至少在过渡期内
- ❌ 不要在服务端存储任何私钥相关数据:这违反了 Passkeys 的安全模型
- ⚠️ 注意 WebAuthn 的浏览器兼容性:Safari 16+、Chrome 108+、Firefox 122+ 支持完整特性
相关工具推荐:
- SimpleWebAuthn — 最成熟的 WebAuthn 库,服务端 + 浏览器端
- FIDO Alliance 元数据服务 — 查询认证器的 AAGUID 和安全等级
- Passkeys debugger — 在线调试 WebAuthn 流程
- jsjson.com 在线工具 — Base64 编解码、JSON 格式化、UUID 生成等开发辅助工具