4. WebAuthn注册流程
WebAuthn 的注册流程,本质上是把“用户持有的认证器”与“服务端账户”建立一次可信绑定。浏览器通过 navigator.credentials.create() 触发认证器生成一对非对称密钥,私钥永远留在认证器内部,公钥则随 attestation 对象一起返回给服务端。服务端完成校验后,把公钥与凭证 ID 存入数据库,后续登录时再用它验证签名。理解这条链路,是做好支付强认证风控的基础。
核心原理:注册阶段到底发生了什么
注册时,服务端先下发一个 PublicKeyCredentialCreationOptions,其中包含 challenge、rp、user、pubKeyCredParams 等字段。浏览器调用 navigator.credentials.create() 后,认证器会:
- 生成新的密钥对,私钥不可导出;
- 把
clientDataJSON、authenticatorData、attestationObject组装成响应; - 由认证器对
authenticatorData || SHA-256(clientDataJSON)签名,证明私钥确实由该认证器持有; - 返回
PublicKeyCredential,其中rawId是凭证 ID,response包含 attestation 数据。
服务端校验的核心是四件事:challenge 是否匹配且未被重放、origin 是否合法、rpIdHash 是否与预期 RP ID 一致、attestation 是否可信。只有这四步都通过,才能把公钥写入账户。
关键点:注册阶段返回的是公钥和凭证 ID,不是私钥。私钥永远不会离开认证器,这正是 WebAuthn 抗钓鱼、抗撞库的根本原因。
方法流程:从浏览器到服务端的完整链路
下面给出一个可落地的注册流程,分为前端调用与服务端校验两段。
// 前端:发起注册
const options = await fetch('/webauthn/register/options', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username: 'alice' })
}).then(r => r.json());
const credential = await navigator.credentials.create({
publicKey: {
challenge: base64urlToBuffer(options.challenge),
rp: { id: 'pay.example.com', name: 'Example Pay' },
user: {
id: base64urlToBuffer(options.userId),
name: 'alice',
displayName: 'Alice'
},
pubKeyCredParams: [
{ type: 'public-key', alg: -7 }, // ES256
{ type: 'public-key', alg: -257 } // RS256
],
authenticatorSelection: {
authenticatorAttachment: 'platform',
userVerification: 'required',
residentKey: 'preferred'
},
timeout: 60000,
attestation: 'direct'
}
});
await fetch('/webauthn/register/verify', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
id: credential.id,
rawId: bufferToBase64url(credential.rawId),
type: credential.type,
response: {
clientDataJSON: bufferToBase64url(credential.response.clientDataJSON),
attestationObject: bufferToBase64url(credential.response.attestationObject)
}
})
});
服务端收到响应后,需要按顺序完成校验。以 Node.js 为例,可使用 @simplewebauthn/server:
import { verifyRegistrationResponse } from '@simplewebauthn/server';
const verification = await verifyRegistrationResponse({
response: req.body,
expectedChallenge: storedChallenge,
expectedOrigin: 'https://pay.example.com',
expectedRPID: 'pay.example.com',
requireUserVerification: true
});
if (!verification.verified) {
return res.status(400).json({ error: 'attestation verify failed' });
}
const { credentialPublicKey, credentialID, counter } = verification.registrationInfo;
await db.saveCredential({
userId: user.id,
credentialID: Buffer.from(credentialID).toString('base64url'),
publicKey: Buffer.from(credentialPublicKey).toString('base64url'),
counter,
createdAt: new Date()
});
校验通过后,服务端应把 credentialID、publicKey、counter、transports 等字段持久化。其中 counter 用于后续登录时检测克隆认证器,transports 用于优化后续认证体验。
案例场景:支付强认证中的注册落地
在支付场景中,注册通常发生在用户首次绑定设备或开启免密支付时。此时风控系统会关注:
- 设备可信度:是否使用平台认证器(如 Touch ID、Windows Hello),attestation 是否来自可信厂商;
- 用户验证:是否要求
userVerification: 'required',确保是本人操作; - 凭证绑定:一个账户可绑定多个凭证,但每个凭证必须唯一,防止凭证 ID 冲突;
- 风控联动:注册成功后触发一次低风险交易验证,确认凭证可用。
例如,某支付平台在注册时要求 attestation: 'direct',并校验 attestation 证书链是否来自 Apple、Google、Microsoft 等可信根。若校验失败,则降级为普通登录,不允许开启免密支付。
| 校验项 | 作用 | 失败处理 |
|---|---|---|
| challenge | 防重放,确保本次注册唯一 | 拒绝注册,重新下发 |
| origin | 防钓鱼,确保来源合法 | 拒绝并告警 |
| rpIdHash | 确保 RP ID 匹配 | 拒绝注册 |
| attestation | 验证认证器可信度 | 降级或拒绝 |
| userVerification | 确认本人操作 | 要求重新验证 |
误区与复盘:注册阶段最容易踩的坑
误区一:把 attestation 当成“必须可信”。很多场景下 attestation: 'none' 已足够,强制 direct 会增加隐私与兼容成本。支付强认证可按风险分级选择。
误区二:challenge 复用或未绑定会话。challenge 必须一次性、随机、与用户会话绑定,否则攻击者可重放注册响应。
误区三:公钥存储格式混乱。建议统一使用 base64url 存储,并保留 credentialID 与 counter,避免后续登录校验时无法解析。
复盘建议:注册成功后立即做一次“验证性登录”,用同一凭证完成一次签名验证,确保公钥存储正确、counter 可递增。这一步能提前暴露 80% 的集成问题。
最后,注册流程的健壮性直接决定后续认证的安全上限。把 navigator.credentials.create() 的调用、attestation 校验、公钥存储与服务端校验串成一条可观测的链路,并在每个环节记录日志与风控指标,才能让 WebAuthn 在支付强认证中真正发挥价值。