2026 年,Google、Apple、Microsoft 三大平台全面支持 Passkeys,全球超过 15 亿个活跃账户已启用无密码登录。传统的「密码 + 短信验证码」模式正在被 WebAuthn 取代——不是因为潮流,而是因为密码泄露导致的数据泄露事件占所有安全事件的 80% 以上,而 WebAuthn 从根本上消除了密码这个攻击面。如果你还在用 bcrypt 哈希存储密码,这篇文章会让你重新思考身份验证的架构。
🔐 一、WebAuthn 核心原理:为什么比密码安全
WebAuthn(Web Authentication API)是 W3C 标准,基于 FIDO2 协议。它的核心思想很简单:用非对称密钥对代替密码。私钥永远不离开用户设备,服务端只存储公钥。即使数据库被拖库,攻击者拿到的公钥也无法用来伪造身份。
🔑 注册与认证的完整流程
WebAuthn 的两个核心操作是注册(Registration)和认证(Authentication)。很多人把这两个流程搞混,导致实现出错。
注册流程:
- 用户在客户端发起注册请求
- 服务端生成随机 Challenge 并返回
- 浏览器调用
navigator.credentials.create(),设备生成密钥对 - 私钥存储在设备安全芯片(TPM/Secure Enclave)中
- 公钥 + 设备信息签名后返回服务端
- 服务端验证签名并存储公钥
认证流程:
- 用户发起登录请求
- 服务端查询该用户的已注册凭证,生成 Challenge
- 浏览器调用
navigator.credentials.get(),用户验证生物识别 - 设备用私钥对 Challenge 签名
- 服务端用存储的公钥验证签名
📌 **记住:**注册和认证是两个完全不同的 API 调用——
create()用于注册,get()用于认证。混淆这两个是 WebAuthn 新手最常犯的错误。
🛡️ 与传统认证方式的安全对比
| 认证方式 | 钓鱼攻击 | 中间人攻击 | 数据库泄露 | 用户体验 | 实现复杂度 |
|---|---|---|---|---|---|
| 密码 + 短信验证码 | ❌ 易受攻击 | ❌ 易受攻击 | ⚠️ 哈希可被暴力破解 | 😞 差 | 低 |
| OAuth2 + JWT | ✅ 较安全 | ⚠️ Token 可被截获 | ✅ 无密码存储 | 🙂 中 | 中 |
| TOTP(Google Authenticator) | ❌ 可被钓鱼 | ⚠️ 一次性码可转发 | ✅ 共享密钥风险 | 😐 中 | 中 |
| WebAuthn/Passkeys | ✅ 域名绑定 | ✅ 签名不可重放 | ✅ 仅公钥无用 | 😊 好 | 高 |
⚡ **关键结论:**WebAuthn 是目前唯一能同时防御钓鱼攻击和中间人攻击的认证方式,因为它将认证域名绑定到了凭证本身——即使用户被诱导到 gooogle.com,浏览器也不会向错误的域名发送凭证。
🚀 二、服务端实现:Node.js + TypeScript 完整代码
理论够了,直接上代码。下面用 @simplewebauthn/server 库实现一个完整的 WebAuthn 服务端。这个库是社区最成熟的 WebAuthn 服务端实现,API 设计清晰。
📦 项目初始化与依赖
# 初始化项目
mkdir webauthn-demo && cd webauthn-demo
npm init -y
npm install @simplewebauthn/server @simplewebauthn/types express
npm install -D typescript @types/express
// src/config.ts - WebAuthn 配置
export const rpName = 'jsjson.com'
export const rpID = 'jsjson.com' // 生产环境必须使用真实域名
export const origin = `https://${rpID}`
⚠️ 警告:
rpID必须与实际访问的域名一致。开发环境下可以用localhost,但不能同时使用127.0.0.1和localhost——它们在 WebAuthn 中是不同的 Relying Party ID。
📝 注册接口实现
// src/routes/register.ts - 注册(生成凭证)
import { generateRegistrationOptions } from '@simplewebauthn/server'
import { verifyRegistrationResponse } from '@simplewebauthn/server'
import type { RegistrationResponseJSON } from '@simplewebauthn/types'
import express from 'express'
import { rpName, rpID, origin } from '../config'
const router = express.Router()
// 临时存储 Challenge(生产环境用 Redis)
const challengeStore = new Map<string, string>()
// 第一步:生成注册选项
router.post('/register/options', async (req, res) => {
const { username } = req.body
// 查询用户已注册的凭证(防止重复注册)
const existingCredentials = await getCredentialsByUser(username)
const options = await generateRegistrationOptions({
rpName,
rpID,
userName: username,
userDisplayName: username,
attestationType: 'none', // 不需要设备证明
authenticatorSelection: {
authenticatorAttachment: 'platform', // 优先使用平台认证器(指纹/面容)
residentKey: 'preferred', // 优先创建可发现凭证
userVerification: 'preferred', // 优先要求用户验证
},
excludeCredentials: existingCredentials.map(cred => ({
id: cred.credentialID,
type: 'public-key',
})),
})
// 存储 Challenge 用于后续验证
challengeStore.set(username, options.challenge)
res.json(options)
})
// 第二步:验证注册响应
router.post('/register/verify', async (req, res) => {
const { username, credential } = req.body as {
username: string
credential: RegistrationResponseJSON
}
const expectedChallenge = challengeStore.get(username)
if (!expectedChallenge) {
return res.status(400).json({ error: 'Challenge 不存在或已过期' })
}
const verification = await verifyRegistrationResponse({
response: credential,
expectedChallenge,
expectedOrigin: origin,
expectedRPID: rpID,
})
if (!verification.verified || !verification.registrationInfo) {
return res.status(400).json({ error: '注册验证失败' })
}
const { credentialPublicKey, credentialID, counter } = verification.registrationInfo
// 存储凭证到数据库
await saveCredential({
username,
credentialID: Buffer.from(credentialID).toString('base64url'),
credentialPublicKey: Buffer.from(credentialPublicKey).toString('base64url'),
counter,
})
// 清除 Challenge
challengeStore.delete(username)
res.json({ verified: true })
})
export default router
🔓 认证接口实现
// src/routes/authenticate.ts - 认证(验证身份)
import { generateAuthenticationOptions } from '@simplewebauthn/server'
import { verifyAuthenticationResponse } from '@simplewebauthn/server'
import type { AuthenticationResponseJSON } from '@simplewebauthn/types'
import express from 'express'
import { rpID, origin } from '../config'
const router = express.Router()
const challengeStore = new Map<string, string>()
// 第一步:生成认证选项
router.post('/login/options', async (req, res) => {
const { username } = req.body
const credentials = await getCredentialsByUser(username)
const options = await generateAuthenticationOptions({
rpID,
userVerification: 'preferred',
allowCredentials: credentials.map(cred => ({
id: cred.credentialID, // base64url 编码的 credentialID
type: 'public-key' as const,
})),
})
challengeStore.set(username, options.challenge)
res.json(options)
})
// 第二步:验证认证响应
router.post('/login/verify', async (req, res) => {
const { username, credential } = req.body as {
username: string
credential: AuthenticationResponseJSON
}
const expectedChallenge = challengeStore.get(username)
if (!expectedChallenge) {
return res.status(400).json({ error: 'Challenge 不存在或已过期' })
}
// 从数据库获取对应的凭证
const storedCredential = await getCredentialById(username, credential.id)
if (!storedCredential) {
return res.status(400).json({ error: '未找到注册凭证' })
}
const verification = await verifyAuthenticationResponse({
response: credential,
expectedChallenge,
expectedOrigin: origin,
expectedRPID: rpID,
credential: {
id: storedCredential.credentialID,
publicKey: Buffer.from(storedCredential.credentialPublicKey, 'base64url'),
counter: storedCredential.counter,
},
})
if (!verification.verified) {
return res.status(400).json({ error: '认证失败' })
}
// 更新 counter(防止重放攻击)
await updateCredentialCounter(
storedCredential.credentialID,
verification.authenticationInfo.newCounter
)
// 生成 JWT 或创建 Session
const session = await createSession(username)
challengeStore.delete(username)
res.json({ verified: true, token: session.token })
})
export default router
💡 提示:
counter机制是 WebAuthn 的重要安全特性。每次认证成功后 counter 递增,服务端必须检查新 counter 大于旧 counter。如果出现 counter 回退,说明可能存在凭证克隆攻击。
💡 三、前端集成与浏览器 API
前端代码比服务端简单得多,核心就是两个 API 调用。
🎨 完整的前端注册与登录
// frontend/webauthn.js - 前端 WebAuthn 集成
// 检测浏览器是否支持 WebAuthn
if (!window.PublicKeyCredential) {
alert('当前浏览器不支持 WebAuthn')
}
// 注册流程
async function register(username) {
// 第一步:从服务端获取注册选项
const optionsResp = await fetch('/api/register/options', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username }),
})
const options = await optionsResp.json()
// 第二步:调用浏览器 WebAuthn API
const credential = await navigator.credentials.create({
publicKey: {
...options,
challenge: base64URLToBuffer(options.challenge),
user: {
...options.user,
id: base64URLToBuffer(options.user.id),
},
},
})
// 第三步:将凭证发送到服务端验证
const verifyResp = await fetch('/api/register/verify', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
username,
credential: serializeCredential(credential),
}),
})
const result = await verifyResp.json()
return result.verified
}
// 登录流程
async function login(username) {
const optionsResp = await fetch('/api/login/options', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username }),
})
const options = await optionsResp.json()
const credential = await navigator.credentials.get({
publicKey: {
...options,
challenge: base64URLToBuffer(options.challenge),
allowCredentials: options.allowCredentials?.map(cred => ({
...cred,
id: base64URLToBuffer(cred.id),
})),
},
})
const verifyResp = await fetch('/api/login/verify', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
username,
credential: serializeCredential(credential),
}),
})
const result = await verifyResp.json()
if (result.verified) {
localStorage.setItem('token', result.token)
}
return result.verified
}
// 工具函数:Base64URL 与 ArrayBuffer 互转
function base64URLToBuffer(base64URL) {
const padding = '='.repeat((4 - (base64URL.length % 4)) % 4)
const base64 = base64URL.replace(/-/g, '+').replace(/_/g, '/') + padding
const binary = atob(base64)
return Uint8Array.from(binary, c => c.charCodeAt(0)).buffer
}
function bufferToBase64URL(buffer) {
const bytes = new Uint8Array(buffer)
let binary = ''
for (const byte of bytes) binary += String.fromCharCode(byte)
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
}
// 序列化凭证对象(浏览器返回的是 ArrayBuffer,需要转为 Base64URL)
function serializeCredential(credential) {
return {
id: credential.id,
type: credential.type,
rawId: bufferToBase64URL(credential.rawId),
response: {
clientDataJSON: bufferToBase64URL(credential.response.clientDataJSON),
attestationObject: credential.response.attestationObject
? bufferToBase64URL(credential.response.attestationObject)
: undefined,
authenticatorData: credential.response.authenticatorData
? bufferToBase64URL(credential.response.authenticatorData)
: undefined,
signature: credential.response.signature
? bufferToBase64URL(credential.response.signature)
: undefined,
},
}
}
⚠️ **警告:**WebAuthn API 在非 HTTPS 环境下(除
localhost外)会被浏览器拒绝。开发时请确保使用localhost或配置本地 HTTPS 证书,否则navigator.credentials.create()会直接抛出NotAllowedError。
🔧 设备绑定策略选择
不同场景需要不同的认证器策略。选错策略会导致用户体验灾难:
| 策略 | authenticatorAttachment | residentKey | 适用场景 |
|---|---|---|---|
| 纯平台认证 | platform |
preferred |
个人设备、手机 App |
| 纯跨设备认证 | cross-platform |
discouraged |
共享电脑、安全密钥 |
| 不限类型 | 不设置 | required |
通用场景、Passkeys |
📌 **记住:**Passkeys 本质上就是 residentKey: 'required' 的平台认证器凭证。它允许用户在不同设备间同步(通过 iCloud Keychain 或 Google Password Manager),是真正的「一次注册,处处登录」。
⚠️ 四、生产环境避坑指南
WebAuthn 的规范很清晰,但生产环境的坑比你想象的多。
🕳️ 常见陷阱
❌ 错误:Challenge 不设过期时间
Challenge 应该有 5 分钟的过期时间。没有过期意味着旧的 Challenge 可以被重放。用 Redis 存储 Challenge 时设置 TTL:
# Redis 存储 Challenge 并设置过期
redis-cli SET "challenge:${username}" "${challenge}" EX 300
❌ 错误:忽略 counter 检查
每次认证成功后,verifyAuthenticationResponse 会返回 newCounter。你必须将其与数据库中存储的旧 counter 对比——如果新 counter 小于或等于旧 counter,说明可能存在凭证克隆。
❌ 错误:rpID 与实际域名不匹配
开发时用 localhost,部署时忘了改成真实域名,导致所有已注册凭证失效。建议通过环境变量管理:
// 通过环境变量管理 rpID
const rpID = process.env.WEBAUTHN_RP_ID || 'localhost'
const origin = process.env.WEBAUTHN_ORIGIN || `http://${rpID}:3000`
❌ 错误:不处理用户已有凭证
注册时没有用 excludeCredentials 排除已注册的设备,导致同一设备重复注册多个凭证,增加管理混乱和安全风险。
✅ 最佳实践清单
- ✅ Challenge 必须是密码学安全的随机数,长度 ≥ 32 字节
- ✅ Challenge 必须设置过期时间(推荐 5 分钟)
- ✅ 注册和认证都必须验证
expectedOrigin和expectedRPID - ✅ 必须检查 counter 递增
- ✅ 使用
userVerification: 'preferred'让设备决定验证方式 - ✅ 提供「管理已注册设备」功能,允许用户删除丢失的设备凭证
- ✅ 保留密码作为降级方案,不要一上来就强制无密码
🎯 总结与建议
WebAuthn/Passkeys 不是未来技术,而是当前最安全的用户认证方案。它的安全性不依赖于用户的安全意识(不用记密码、不会被钓鱼),而是依赖于密码学和硬件安全芯片。
落地建议:
- 渐进式迁移:先作为第二因素(MFA)上线,用户体验习惯后再升级为首选方式
- 保留降级方案:永远保留密码或邮箱链接作为备用登录方式,防止用户丢失设备
- 优先支持 Passkeys:
residentKey: 'required'+ 平台认证器 = 跨设备同步,用户体验最好 - 监控认证指标:跟踪注册率、认证成功率、设备类型分布,持续优化
| 需求 | 推荐方案 | 理由 |
|---|---|---|
| 快速集成 | SimpleWebAuthn | 社区最成熟,API 清晰 |
| 全栈框架 | NextAuth.js v5 | 内置 WebAuthn Provider |
| 企业级 | Auth0 / Clerk | 托管服务,省心省力 |
| 自研可控 | 自己实现 | 参考本文代码 |
WebAuthn 的学习曲线确实比密码认证高,但一旦跑通,你会发现它不仅更安全,而且用户反馈也更好——没有人喜欢记密码。