THE COMPLETE GUIDE · 全文版
Passkey 中文开发指南
以 W3C WebAuthn Level 2 为基础,面向开发实践的中文编译学习文档。非官方译文;现代 Passkey 补充已标注。
CHAPTER 01
从英文规范开始,学会 Passkey 开发
Passkey(通行密钥)让用户通过设备上的解锁方式登录网站。对开发者来说,核心变化是:服务器保存公钥,登录时验证一次性挑战的签名,而不是接收用户输入的密码。指纹、面容或设备 PIN 用于本地授权,不会作为 WebAuthn 响应发送给网站。
这份文档面向已经了解 HTTP、JavaScript 和基本登录流程的开发者。目标是读懂 WebAuthn 的安全边界,理解注册与登录的数据流,并通过一个可运行的示例把浏览器 API 和服务器验证连接起来。
这份中文文档与英文原文的关系#
你提供的链接是 W3C 的 Web Authentication Level 2,发布于 2021 年 4 月 8 日。它是一份规范,涉及浏览器、认证器和网站服务器的行为;它并非一篇按现代 Passkey 产品体验组织的开发教程。
本书将原规范中与网站开发最相关的部分重新编排,提供中文整理、关键语义译解、字段说明及新增代码讲解。这是非官方的学习版本,不是逐条完整译本;原规范的算法细节、符合性条款和所有证明格式仍应查阅英文。正文中的“译解”保留原意,其他解释、示例、取舍建议属于本书补充。出处、修改说明和文档使用规则见参考资料与版权说明。
截至本书核对日期 2026 年 10 月 16 日,WebAuthn Level 3 已于 2026 年 8 月 25 日成为 W3C Recommendation。下文以你指定的 Level 2 为基础;同步通行密钥、条件式界面和新的 JSON 辅助方法等现代内容,会明确标为补充,避免把后来的能力误认为 2021 年原文已有的要求。
先理解三个层次#
| 层次 | 解决的问题 | 你主要接触的内容 |
|---|---|---|
| Passkey 用户体验 | 如何让用户创建、选择、使用通行密钥 | 注册入口、系统选择器、账户恢复、凭据管理 |
| WebAuthn | 网页如何请求创建凭据、如何获得认证断言 | navigator.credentials.create()、navigator.credentials.get() |
| 认证器与通信机制 | 私钥由谁保护、如何与客户端协作 | 平台认证器、安全密钥;外部认证器可能通过 CTAP 等机制通信 |
不要把“Passkey”理解为某个 JavaScript 库,也不要把“FIDO2”理解为一条独立的 HTTP 登录接口。WebAuthn 负责浏览器侧的标准接口和相关协议要求;你仍然要实现服务器端的挑战管理、响应验证和会话管理。
FIDO 对 Passkey 的说明包含同步型和设备绑定型凭据。是否能够同步、同步到哪些设备、如何恢复,是凭据提供者与平台能力的一部分;网站不能仅凭浏览器 create() 成功,就推断凭据一定已经备份到云端。FIDO 官方 Passkey 介绍
原规范导读:两种 ceremony#
“Ceremony”在这里译为“交互流程”:它不仅是一次密码学运算,还包括服务器请求、浏览器判断来源、认证器与用户交互以及服务器最终校验。
**§1 引言译解:**网站通过两种相关流程使用 WebAuthn。注册时,认证器为该网站创建公钥凭据,并把它关联到当前用户的账户;认证时,网站接收认证断言,以验证注册该凭据的用户参与并同意了本次操作。英文原文 §1 Introduction
把它改写成开发任务:
- 注册:先确认“给哪个账户添加凭据”,再请求生成密钥;验证响应后保存凭据 ID、公钥与账户的关系。
- 登录:先发出新的随机挑战,再验证已有私钥对本次上下文的签名;验证全部通过后建立登录会话。
这里的“注册凭据”不等于“新建用户”。一个已有账户可以添加多个凭据;用户也可以先创建账户,再完成通行密钥注册。网站必须确定这两步之间的可信账户绑定,不能任由客户端在完成注册时提交另一个账户 ID。
建议学习路线#
| 阶段 | 阅读内容 | 完成后应能解释 |
|---|---|---|
| 建立模型 | 术语与安全边界、注册与认证协议 | RP ID、origin、UP/UV,以及谁持有私钥 |
| 接入浏览器 | 注册详解、认证详解 | 创建与获取凭据所需字段,JSON 与二进制转换 |
| 接入服务器 | 服务端验证章节与完整示例 | 为什么不能仅凭浏览器返回成功就登录 |
| 上线前检查 | 安全、恢复、测试与部署章节 | 挑战的一次性、域名绑定、账户恢复与真实设备验证 |
| 深入规范 | 原文 §6、§8、§9、§10 | 认证器数据结构、证明格式和扩展机制 |
如果你是在已有网站中增加登录方式,优先完成“已登录用户添加凭据 → 验证通行密钥登录 → 凭据管理与恢复”这条闭环。这样可以先解决账户绑定,再研究无用户名登录和自动填充等交互。
一个最小心智模型#
假设网站为 https://login.example.com,用户是“小林”。
注册后,服务器保存类似以下信息:
账户: user_42
用户句柄: 随机的、不含邮箱的字节序列
凭据 ID: 认证器生成的凭据标识
凭据公钥: 与该凭据对应的验证公钥
计数器等状态: 验证器返回并按策略维护再次登录时,服务器生成随机挑战 challenge_A。浏览器请求认证器对绑定到该网站的凭据进行操作。认证器授权后产生签名;服务器使用之前保存的公钥,连同挑战、来源、RP ID 等一起验证。
如果攻击者复制上一轮响应,challenge_A 已失效,不能用于新一轮登录。如果攻击者搭建另一个域名的钓鱼网站,标准客户端会根据调用来源限制它可使用的 RP ID。防重放依赖服务器正确管理挑战,防钓鱼依赖完整执行来源与 RP 绑定校验,两者都不能靠前端 UI 替代。
Passkey 没有替你完成什么#
你仍然需要决定“谁能添加或删除一个凭据”“丢失所有凭据后如何恢复”“成功认证后如何保护会话”。通行密钥减少密码泄露、复用和钓鱼风险,但恶意脚本、会话窃取、薄弱的账户恢复流程和错误的服务器验证仍可能让账户被接管。
静态文档可以发布到 Cloudflare Pages,浏览器也能在 HTTPS 页面调用 WebAuthn;真正的多用户登录还需要可信服务器或边缘函数、持久化存储和安全会话。本书中的学习网站与可运行示例会清楚区分这两个层次。
读原文时的定位方法#
W3C 原文会用 MUST、SHOULD、MAY 区分要求强度。可分别理解为“必须”“应当”“可以”,不要把 SHOULD 当成可随意忽略的备注。原文也明确标注某些部分为非规范性说明;用例和示例帮助理解,实现验证规则要回到规范性算法。
网站开发者最值得反复对照的是§7 WebAuthn Relying Party Operations,而不是照抄 §1 的演示代码。原文给出的§1.1 阅读路线同样强调了这一点。
学习检查#
create()成功后,为什么服务器还必须验证响应?- 服务器拥有公钥之后,为什么仍需每次生成不同的 challenge?
- 为什么“给已有账户增加凭据”通常比从零设计无密码开户更适合作为第一步?
带着这三个问题进入下一章。它们对应的是信任边界、重放防护和账户绑定,也是最容易被“几行前端代码”掩盖的核心问题。
CHAPTER 02
术语与安全边界
学习 WebAuthn,先把“网站、浏览器、认证器、账户、凭据”区分开。它们承担不同职责;错误地把某一方返回的数据直接当成可信事实,往往比加密算法选错更容易造成漏洞。
英文原文 §4 Terminology · §6 WebAuthn Authenticator Model
参与方:谁信任谁#
| 英文术语 | 本书译法 | 具体职责 |
|---|---|---|
| Relying Party / RP | 依赖方,通常就是你的网站 | 产生请求,保存公钥,验证响应,管理账户和会话 |
| WebAuthn Client / User Agent | WebAuthn 客户端 / 用户代理 | 通常由浏览器与系统协作;检查调用来源、协调认证器、组织客户端数据 |
| Authenticator | 认证器 | 创建或使用凭据密钥,执行用户在场 / 验证操作,产生响应 |
| User | 用户 | 选择账户、授权创建或使用凭据 |
| Credential | 凭据 | 面向某个 RP 的认证材料;不能简单等同于一个用户账户 |
| Ceremony | 交互流程 | 由多方共同完成的注册或认证过程 |
网页脚本不能读取凭据私钥。网站收到的是凭据标识、公钥相关材料、签名及上下文数据。生物识别通常在认证器本地执行;网站只获得 UP/UV 等结果标志,不获得指纹图像、人脸模型或设备 PIN。英文原文 §14.3 本地生物识别
“不把私钥发给网站”并不等于“私钥永远只存在于一块硬件里”。现代同步型通行密钥可能由提供者在受保护的同步体系中同步;设备绑定型凭据有不同的保护与恢复模型。网站需要依据实际政策和验证结果判断保证程度,不能从一个指纹弹窗推断密钥一定不可导出。
origin 与 RP ID:一个精确来源,一个凭据作用域#
origin 是来源,通常包含协议、主机与端口;例如 https://login.example.com:8443。它不包含路径。
RP ID 是凭据的作用域标识,采用域名形式,没有协议、端口或路径。对于 Level 2 的常规 Web 场景,RP ID 必须是当前有效域名本身,或该域名的可注册域后缀;不能随意指定别人的域名,也不能使用公共后缀。英文原文:RP ID 定义
| 页面 origin | 请求的 RP ID | 是否符合常规域名关系 |
|---|---|---|
https://login.example.com:8443 |
login.example.com |
是,精确主机 |
https://login.example.com:8443 |
example.com |
是,可注册父域 |
https://login.example.com:8443 |
m.login.example.com |
否,是子域而非父域 |
https://login.example.com:8443 |
com |
否,是公共后缀 |
https://evil.example.net |
example.com |
否,域名不匹配 |
浏览器的 RP ID 检查和服务器的 origin 检查解决不同问题。即便 login.example.com 和 admin.example.com 都能在域名规则上使用 example.com,服务器仍必须按本系统允许的 origin 精确验证。不要用字符串包含、模糊后缀匹配或接受所有 *.example.com 来代替明确的来源策略。
**域名选择是长期设计决策。**以 example.com 注册的凭据,不会因为你把新网站放到 example.net 就自动迁移。测试站点、Pages 预览 URL、生产域名应有明确边界;正式接入前应先确定稳定的生产 RP ID。英文原文 §7.1 注册验证
三种 ID,不要混用#
| 字段 | 谁生成 | 用途 | 常见错误 |
|---|---|---|---|
user.id / user handle |
RP 服务器 | 用不透明字节稳定标识本网站账户 | 使用邮箱、昵称或每次都变化的随机值 |
credential ID / rawId |
认证器 | 找到某个具体凭据及其公钥 | 把它当成秘密,或当成整个账户的唯一凭据 |
| challenge | RP 服务器 | 绑定并区分本次操作,防止重放 | 写成固定值、由客户端决定、使用后不失效 |
用户句柄在创建时必须是 1~64 字节;建议给每个账户分配稳定的随机值,不包含用户名或邮箱。一个账户的多个凭据通常使用同一个 user handle。user.name 和 user.displayName 是展示字段,可改变,也可能重复;认证与授权不能依据显示名称决定。英文原文 §5.4.3 用户参数
credential ID 是不透明字节。网站应原样保存,不解析内部格式,不根据长度猜设备厂商。一个账户允许多个 credential ID,例如电脑内置认证器和备用安全密钥;一个 credential ID 对应的账户关系应由服务器保存并强制校验。
challenge:公开,但必须不可预测且只用一次#
challenge 不是密码,不需要向客户端保密;它的价值在于攻击者无法事先预测并重复使用。服务器使用密码学安全随机数生成器产生,记录预期值、操作类型、关联账户或会话和过期时间;完成验证时比对并消费。
原规范建议挑战至少 16 字节;本书示例采用 32 字节随机值。长度只是基本条件,Math.random() 拼成的字符串、时间戳和递增序号都不能替代安全随机数。timeout 只影响客户端交互,不会替服务器使挑战失效。英文原文 §13.4.3 Cryptographic Challenges
// 服务器思路示意;不是可接受客户端自报 challenge 的接口。
const bytes = crypto.getRandomValues(new Uint8Array(32));
// 将 bytes 对应的挑战与操作、会话、截止时间保存在服务器。
// 验证响应时读取服务器记录,而非从提交内容中建立“预期值”。UP 与 UV:在场和验证不是同一个保证#
| 标志 | 含义 | 典型操作 | 网站能否据此推断身份细节 |
|---|---|---|---|
| UP:User Presence | 检测到用户在场并参与 | 触碰安全密钥按钮等 | 不能证明触碰者是哪位用户 |
| UV:User Verification | 认证器执行了本地用户验证 | 设备 PIN、指纹、面容等 | 不能由此知道使用了哪种具体生物特征 |
userVerification: "required" 表示本次操作必须执行 UV,服务器也必须检查响应的 UV 位。"preferred" 表示希望执行,但无法执行时不一定失败;"discouraged" 表示不要求 UV,不是强制禁止认证器执行。不要把 preferred 当成“总是要求指纹”。英文原文 §5.8.6 UV 需求
要求无密码、单步骤登录时,本书示例选择 required 作为学习策略。更广的兼容性策略需要结合风险、是否还有其他因素及用户群体确定。无论选什么值,服务器必须按自己的预期验证,不能信任客户端声称“已经验证”。
认证器类型与凭据类型是两条轴#
平台认证器(platform)通常集成在当前设备中;漫游认证器(roaming / cross-platform)可以用于不同设备,例如外接安全密钥。这描述的是认证器与客户端的附着关系。
可发现凭据(discoverable credential)允许客户端在服务器未提前列出凭据 ID 时找到可用于该 RP 的账户;不可发现凭据需要服务器在 allowCredentials 中提供 ID。旧名称 resident / non-resident 仍留在字段中,如 residentKey。这描述的是凭据发现能力。英文原文 §6.2 认证器分类
因此,“平台 = 可发现”“安全密钥 = 不可发现”“可发现 = 已同步到云端”都不是成立的等式。现代通行密钥学习中应分别检查:能否发现、是否执行 UV、是否允许备份、是否已经备份、认证器采用何种保护方式。
attestation 与 assertion#
attestation 译为“证明”,主要用于注册,可能为认证器来源或属性提供可验证信息。assertion 译为“认证断言”,主要用于登录,证明持有该凭据私钥的一方为本次挑战进行了操作。
普通消费类应用通常不需要识别硬件厂商,可采用 attestation: "none" 作为起点。这不代表跳过注册响应验证,也不代表公钥不需要解析;服务器仍然要验证挑战、来源、RP ID、标志及凭据结构。需要企业设备合规或特定硬件保证时,再设计证明信任策略和元数据维护。英文原文 §6.5 Attestation
学习检查#
- 指纹验证成功,网站究竟获得了什么信息?
user.name从“小林”改成邮箱后,为什么不应该影响已有凭据与账户的绑定?- RP ID 正确时,为什么仍必须验证完整 origin?
allowCredentials为空,会允许攻击者不提供签名直接登录吗?
最后一题的答案是否定的。它改变的是“如何选择凭据”,服务器仍然必须找到已登记的公钥并完成全部认证验证;下一章会把这条信任链串起来。
CHAPTER 03
注册与认证:把信任链串起来
WebAuthn 的浏览器调用各只有一个主要入口,但一次安全登录需要服务器、客户端、认证器和用户共同完成。理解每一步携带什么、验证什么,才能判断代码是否真正实现了认证。
英文原文 §1.2 用例 · §1.3 API 示例 · §7 RP 操作
原文重点:你链接的登录场景#
你提供的锚点是 §1.2.2 Authentication。下面是该2021 年用例的中文译解,用来理解电脑与手机协作的角色,不应照搬为现代用户操作说明:
- 用户在电脑上通过蓝牙与手机配对,打开
example.com并发起登录;浏览器提示到手机完成操作。 - 手机显示登录该网站的通知;用户打开通知,从账户列表中选择身份,再通过已配置的 PIN、生物识别等方式授权。
- 电脑上的网站显示所选账户已登录,并进入登录后的页面。
**现代补充:**今天的跨设备通行密钥体验可能由浏览器显示二维码并通过 hybrid 等机制协调。开发者应交给系统界面引导,不应要求所有用户先手工完成传统蓝牙配对。设备、浏览器和凭据提供者能力不同,也可能直接使用已同步到当前设备的通行密钥。Level 3 消费者多设备用例
这段用例说明了一个重要事实:执行网页的设备与使用私钥的设备可以不同。对 RP 服务器来说,最终仍然要验证同一套带上下文的响应,不能因为界面看起来像“手机确认”就降低验证要求。
注册:从已有账户到一个新公钥#
用户 RP 服务器 浏览器 / 系统 认证器
| 已确认账户 | | |
|--------------->| | |
| | 生成并保存 challenge | |
| |----注册选项---------->| |
| | | create({ publicKey }) |
| | |----请求创建---------->|
|<--------------------------显示账户 / 请求本地授权---------------|
|---------------------------同意并完成 UV----------------------->|
| | |<---凭据与证明数据------|
| |<---注册响应 JSON-------| |
| | 验证并保存公钥 / ID | |
|<----添加成功----| | |注册选项包括 RP 标识、用户句柄、随机挑战、支持的算法和认证器选择要求。浏览器会检查安全上下文和 RP ID 关系,组织客户端数据;认证器创建凭据并返回认证器数据及相关证明材料。
服务器收到响应后不能直接保存客户端随便提交的“公钥字符串”。它要依据§7.1 注册算法验证客户端数据、认证器数据、证明及本地策略,然后将验证得到的凭据公钥绑定到服务器早已确定的账户。
对不请求设备证明的普通应用,attestation: "none" 允许避免复杂证明信任链;这不会免除来源、挑战、RP ID 或 UV 检查。注册是否具有特定硬件来源保证,与“新公钥已安全绑定到哪个账户”是相关但不同的判断。
认证:用已保存公钥验证新挑战#
用户 RP 服务器 浏览器 / 系统 认证器
| 点击登录 | | |
|--------------->| | |
| | 生成新的 challenge | |
| |----认证选项---------->| |
| | | get({ publicKey }) |
| | |----请求断言---------->|
|<--------------------------选择账户 / 完成本地验证---------------|
| | |<---签名及上下文--------|
| |<---认证响应 JSON-------| |
| | 查公钥并完成全部验证 | |
| | 消费 challenge / 建会话| |
|<----登录成功----| | |服务器可以先让用户输入用户名,再下发该账户的 allowCredentials;也可以省略列表,依靠可发现凭据让用户在系统界面选择账户。二者的差别是凭据发现和账户定位方式,不改变签名与上下文校验的必要性。英文原文 §7.2 认证算法
签名到底覆盖什么#
认证时,签名的核心输入为:
authenticatorData || SHA-256(clientDataJSON)|| 表示字节拼接。clientDataJSON 是浏览器提供的原始 UTF-8 JSON 字节,其中包含操作类型、challenge、origin 等。authenticatorData 包含 RP ID 的哈希、UP/UV 标志、签名计数器,以及可能存在的扩展数据。英文原文 §6.1 Authenticator Data · §5.8.1 客户端数据
服务端必须对收到的原始 clientDataJSON 字节做哈希。先解析为对象、再 JSON.stringify() 会改变空白、属性顺序等,可能得到不同字节。解析用于检查字段,原始字节用于签名输入,两项工作都需要。
公钥验证通过,只能说明签名与该输入相符;如果服务器没检查其中的挑战或 origin,攻击者仍可能把不属于当前操作的有效签名提交过来。因此,“调用验签函数返回 true”不等于“完整认证通过”。
密码登录与 WebAuthn 的差别#
| 维度 | 常见密码登录 | WebAuthn 登录 |
|---|---|---|
| 用户侧认证材料 | 需记忆或由密码管理器填写的共享秘密 | 由认证器管理的凭据私钥 |
| 服务器主要保存 | 加盐密码哈希及参数 | 凭据 ID、公钥、账户绑定与状态 |
| 每次请求的核心证明 | 用户提交密码,服务器检查 | 对新挑战与上下文的签名 |
| 假站风险 | 用户可能把密码交给假站 | 标准客户端限制凭据的 RP 作用域 |
| 账户恢复 | 常见邮件 / 短信 / 人工流程 | 仍需设计;可结合备用凭据与恢复机制 |
| 服务器验证难点 | 密码哈希、限速、泄露防护 | 挑战生命周期、来源 / RP 绑定、签名及标志验证 |
公钥被读取不等于私钥泄露,因此服务器凭据库泄露的后果与密码哈希库不同;但账户信息、凭据 ID 和元数据仍有隐私与攻击价值,不能因此放弃访问控制或审计。
首次开户、添加凭据、重新认证#
这三个业务流程都可能调用 create() 或 get(),但授权条件不同:
- **首次开户:**先确定产品如何分配账户和验证联系方式;WebAuthn 不会自动证明用户拥有某个邮箱。
- **添加凭据:**必须基于可信账户上下文,例如已有登录会话,并视风险要求近期重新认证;防止被盗会话轻易增加攻击者凭据。
- **敏感操作重新认证:**使用新的 challenge,明确绑定当前账户和操作;成功后授予有限时间或一次性的操作权限。
原文的§1.2.3 新设备注册用例展示了先用已有安全密钥登录,再添加新设备平台认证器的思路。它也说明一个账户允许多凭据,对设备更换和恢复很有价值。
浏览器响应与服务器响应要分开#
浏览器弹出成功动画,仅表示本地凭据操作完成;服务器可能因 challenge 过期、origin 不符或账户绑定错误而拒绝。前端应等待验证接口确认后再显示“登录成功”。
同样,NotAllowedError 可能涵盖用户取消、超时或无合适凭据等多种情况。页面应提供“未完成本次操作,可重试或选择其他方式”的正常反馈,而不是断言用户没有通行密钥或密码错误。
学习检查#
尝试分别说出注册和认证时服务器“发什么、记什么、验什么、存什么”。如果回答里缺少服务器保存的预期 challenge,或者只提到公钥验签,那么信任链还没有闭合。接下来的两章将把请求与返回字段逐一对应到这条链上。
CHAPTER 04
注册详解:安全地绑定一把新公钥
注册的目标是:让认证器创建一个面向本网站的凭据,然后由服务器验证并绑定到正确账户。navigator.credentials.create() 只完成浏览器侧的凭据创建;服务器验证成功,才算业务上的添加完成。
英文原文 §5.4 创建选项 · §5.2.1 注册响应 · §7.1 服务端注册算法
开始之前:先确定注册权限#
对于已有账户,注册接口应从可信登录会话读取账户 ID。不要接收一个客户端可随意修改的 userId,然后给这个账户添加公钥。敏感账户还应要求近期重新认证,并为成功添加或删除凭据生成审计记录。
服务器先生成随机 challenge,连同当前账户、注册操作和过期时间暂存。请求返回的选项和稍后收到的响应必须属于同一次流程;验证时消费挑战,防止重复提交、并发竞态和跨账户绑定。
创建选项逐项理解#
| 字段 | 类型 / 例子 | 它控制什么 | 应注意什么 |
|---|---|---|---|
challenge |
浏览器中为字节;JSON 中常用 Base64URL | 绑定本次注册请求 | 由服务器安全随机生成并保存预期值 |
rp.id |
example.com |
新凭据的 RP 作用域 | 不能带协议 / 端口 / 路径;应长期稳定 |
rp.name |
示例学习站 |
Level 2 中的 RP 显示名称 | 展示字段,不能作为安全身份 |
user.id |
1~64 字节 | 账户的稳定 user handle | 不包含邮箱等个人身份信息 |
user.name |
xiaolin@example.com |
供用户识别的账户名 | 不是认证授权依据;考虑展示隐私 |
user.displayName |
小林 |
友好的显示名称 | 可以与别的账户重复 |
pubKeyCredParams |
[{type:"public-key",alg:-7}] |
RP 接受的公钥算法,按偏好排列 | 服务端验证器必须实际支持选中的算法 |
excludeCredentials |
已绑定凭据的描述列表 | 避免重复创建已有凭据 | ID 解码为字节;服务器仍需检查凭据唯一性 |
authenticatorSelection |
选择条件对象 | 可发现性、UV、附着类型要求 | 需求过严可能排除用户可用的认证器 |
attestation |
通常从 "none" 开始 |
是否请求认证器证明 | 不等于是否验证注册响应 |
timeout |
毫秒,如 60000 |
客户端等待时间提示 | 浏览器可调整;服务端另设过期策略 |
extensions |
如 {credProps:true} |
请求可选扩展 | 可能被忽略,不能假定一定有输出 |
其中 -7 表示 ES256,-257 表示 RS256。这些是 COSE 算法标识,不是任意数字;不要把它们与证书签名算法或 HTTP 层 TLS 算法混为一谈。英文原文 §5.8.5 算法标识
为通行密钥选择创建策略#
本书学习示例采用以下策略,适合练习可发现凭据与本地用户验证:
// 这是服务器产生创建选项时采用的策略片段。
const registrationPolicy = {
authenticatorSelection: {
residentKey: "required",
requireResidentKey: true,
userVerification: "required",
},
attestation: "none",
extensions: { credProps: true },
};residentKey: "required" 要求创建可发现凭据;无法满足时应失败。requireResidentKey: true 用于兼容较旧实现,保持与 residentKey 的要求一致。userVerification: "required" 要求完成本地用户验证,后端也必须执行对应检查。英文原文 §5.4.4 选择条件
这里没有强制 authenticatorAttachment: "platform",以便允许浏览器呈现适合的认证器选择。设置 platform 会限制为平台认证器,并不能保证“同步到所有设备”;设置 cross-platform 也不等于“必须是 USB”。选择附着类型应服务于业务要求,不应仅为了让演示弹出某一种系统窗口。
residentKey: "preferred" 可以扩大兼容范围,但可能得到不可发现凭据。若采用它,产品必须准备先输入用户名等登录路径,不能保证所有注册结果都能进行无用户名登录。credProps.rk 输出可帮助了解创建结果,缺少输出也不能简单推断为 false。英文原文 §10.4 Credential Properties
JSON 与二进制:最常见的接入错误#
HTTP JSON 不直接表达 ArrayBuffer。通常约定将二进制字段编码为 Base64URL 字符串;浏览器收到后,把 challenge、user.id、excludeCredentials[].id 解码为字节,再调用 create()。
Base64URL 用 -、_ 代替普通 Base64 的 +、/,通常省略末尾的 =。它是编码方式,不是加密。不要用 TextEncoder.encode(base64urlString) 解码:那会得到“编码字符串本身”的 UTF-8 字节。
以下辅助函数针对本文的无填充 Base64URL 约定;真实项目可使用经过验证的库。服务器仍须限制输入大小、验证编码与结构,不应把浏览器侧校验当成安全防线。
function fromBase64url(value) {
if (typeof value !== "string" ||
!/^[A-Za-z0-9_-]*$/.test(value) || value.length % 4 === 1) {
throw new TypeError("无效的 Base64URL 字符串");
}
const base64 = value.replace(/-/g, "+").replace(/_/g, "/");
const padded = base64 + "=".repeat((4 - base64.length % 4) % 4);
return Uint8Array.from(atob(padded), c => c.charCodeAt(0));
}
function toBase64url(value) {
const bytes = ArrayBuffer.isView(value)
? new Uint8Array(value.buffer, value.byteOffset, value.byteLength)
: new Uint8Array(value);
let binary = "";
for (const byte of bytes) binary += String.fromCharCode(byte);
return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
}
async function postJSON(path, body = {}) {
const response = await fetch(path, {
method: "POST",
credentials: "same-origin",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
if (!response.ok) throw new Error("操作失败,请重试");
return response.json();
}原生 API 注册示例#
这段是浏览器端集成示例,假设后端已实现同源的两个接口:/api/registration/options 直接返回 JSON 格式创建选项;/api/registration/verify 验证并保存凭据,通过服务端会话绑定账户和挑战。可运行的后端与浏览器源码见完整示例。
async function registerPasskey() {
if (!window.isSecureContext || !window.PublicKeyCredential) {
throw new Error("当前环境不支持 WebAuthn 安全调用");
}
const options = await postJSON("/api/registration/options");
const publicKey = {
...options,
challenge: fromBase64url(options.challenge),
user: { ...options.user, id: fromBase64url(options.user.id) },
excludeCredentials: (options.excludeCredentials ?? []).map(item => ({
...item,
id: fromBase64url(item.id),
})),
};
const credential = await navigator.credentials.create({ publicKey });
if (!(credential instanceof PublicKeyCredential)) {
throw new Error("未创建公钥凭据");
}
const response = credential.response;
await postJSON("/api/registration/verify", {
id: credential.id,
rawId: toBase64url(credential.rawId),
type: credential.type,
clientExtensionResults: credential.getClientExtensionResults(),
response: {
clientDataJSON: toBase64url(response.clientDataJSON),
attestationObject: toBase64url(response.attestationObject),
transports: response.getTransports?.() ?? [],
},
});
// 到这里才可向用户显示“通行密钥添加成功”。
}普通界面应从清楚的“添加通行密钥”按钮触发,并在操作期间管理按钮状态。失败后恢复操作入口,把取消和超时作为正常情况处理。不要在页面加载时不断弹出注册提示。
这个 postJSON 仅演示传输。服务器还需验证请求来源、会话和 CSRF 策略;设置 Content-Type 或依赖“按钮只能被本人点击”都不能替代服务端权限检查。若扩展包含额外二进制输出,通用序列化器还需要针对扩展处理;本文代码只使用输出为布尔值的 credProps。
服务器应保存什么#
在完成规范校验之后,通常至少保存:账户 ID、稳定 user handle、credential ID、公钥字节及算法、签名计数器,以及可用于后续交互的 transport 提示。允许用户给凭据设置“办公室电脑”等备注,但不要把备注当作已验证的设备型号。
现代同步型凭据的备份状态与计数器处理有额外注意事项,应使用支持相应规范的验证器。不要只复制 Level 2 中某个字段判断片段,就断言所有现代通行密钥必须有严格递增计数器。
删除凭据时,服务器可以停止接受该 credential ID;这不等于删除了用户设备或同步提供者中的凭据。产品应说明这种区别,并避免删除账户最后一个可用登录 / 恢复途径后让用户被锁在账户外。
排错与自测#
| 现象 | 优先检查 |
|---|---|
调用即报 TypeError |
user.id 长度、必填字段、Base64URL 是否解码为字节 |
SecurityError |
HTTPS、RP ID 是否允许、iframe / 权限策略 |
NotAllowedError |
用户取消、超时、无法满足 residentKey / UV 等要求 |
| 浏览器成功、服务器失败 | challenge 是否属于同一会话、origin / RP ID 配置、编码是否改变 |
| 相同账户重复注册行为不符预期 | excludeCredentials、user handle 稳定性、认证器的凭据管理行为 |
最值得做的反向测试是:修改返回响应里的 challenge、换一个 origin、把注册结果提交给另一个账户会话。这些情况必须由服务器拒绝,而不仅仅是在正常路径上看到一次“添加成功”。
CHAPTER 05
认证详解:从签名到可信登录会话
认证使用 navigator.credentials.get() 请求已有凭据产生断言。真正决定登录成功的是服务器:它必须找到已登记公钥,验证本次响应和预期上下文,再签发或更新登录会话。
英文原文 §5.5 认证请求选项 · §5.2.2 认证响应 · §7.2 验证认证断言
先选登录路径#
路径 A:先确定用户名#
用户先输入用户名或邮箱。服务器查出对应账户已登记的 credential ID,在 allowCredentials 中发送给浏览器。浏览器请求认证器使用这些凭据之一。
它适合兼容不可发现凭据,也适合需要先定位租户或账户的系统。服务器必须验证返回的凭据确实属于预期账户,且符合本次下发的允许列表,不能只按“签名有效”就接受任意账户的凭据。
查询接口还要考虑账户枚举:未知用户名与已知用户名在响应、错误消息、耗时和限速策略上的差异,可能暴露账户是否存在。不要在公开错误里直接显示“此邮箱存在,但尚未注册通行密钥”。英文原文 §14.6.2 Username Enumeration
路径 B:先选择通行密钥#
不提前输入用户名,服务器省略 allowCredentials 或发送空列表。客户端依靠可发现凭据,为该 RP 提供账户选择。此路径要求用户具有可发现凭据;普通不可发现凭据不会因列表为空而自动变得可发现。
服务器收到响应后,利用已登记的 credential ID 定位凭据与账户;还要验证 userHandle 与该凭据绑定的账户一致。此类未预先识别用户的流程应要求非空的有效 user handle,并将其与数据库中的稳定用户句柄比对,不能信任客户端额外附带的显示名称或账户 ID。英文原文 §7.2 第 6 步的账户识别规则
| 问题 | 先输入用户名 | 先选择通行密钥 |
|---|---|---|
allowCredentials |
当前账户的已登记凭据列表 | 省略或为空 |
| 是否支持不可发现凭据 | 支持 | 通常不适用 |
| 账户何时确定 | 发出断言请求前 | 验证断言时通过登记记录确定 |
| 核心额外检查 | 凭据属于预期账户和列表 | credential ID 与 user handle 一致地映射到已登记账户 |
| 主要体验取舍 | 多一步输入,定位明确 | 输入更少,依赖可发现凭据与账户选择界面 |
两种路径都要验证 challenge、origin、RP ID、UP/UV 和签名;不输入用户名只是移除了一个账户定位步骤,不会移除安全检查。
请求选项#
| 字段 | 例子 | 注意事项 |
|---|---|---|
challenge |
服务器新生成的随机字节 | 与本次认证会话绑定、短时有效且只能使用一次 |
rpId |
example.com |
必须与凭据创建时 RP ID 一致;注意字段名与注册的 rp.id 不同 |
allowCredentials |
{type,id,transports} 列表 |
ID 在调用前从 Base64URL 解码;transport 是交互提示 |
userVerification |
"required" |
服务端也必须检查 UV;preferred 无法提供必然 UV 的保证 |
timeout |
60000 |
客户端提示,不能代替服务器过期时间 |
extensions |
可选扩展输入 | 只请求理解且实际需要的扩展 |
allowCredentials[].transports 可来自注册时的 getTransports(),有助于客户端呈现合适选择。它不是服务器证明私钥在何处的可信证据,也不应被误用为授权条件。英文原文 §5.8.3 凭据描述
原生 API 登录示例#
以下代码复用注册章中的 fromBase64url()、toBase64url() 和 postJSON()。假设 /api/authentication/options 直接返回 JSON 请求选项,服务器通过会话或等效机制保存预期挑战;/api/authentication/verify 只有在完整验证通过后才建立会话。
async function signInWithPasskey() {
if (!window.isSecureContext || !window.PublicKeyCredential) {
throw new Error("当前环境不支持 WebAuthn 安全调用");
}
// 此处为可发现凭据登录;服务端不预先指定用户名。
const options = await postJSON("/api/authentication/options");
const publicKey = {
...options,
challenge: fromBase64url(options.challenge),
};
if (options.allowCredentials !== undefined) {
publicKey.allowCredentials = options.allowCredentials.map(item => ({
...item,
id: fromBase64url(item.id),
}));
}
const credential = await navigator.credentials.get({ publicKey });
if (!(credential instanceof PublicKeyCredential)) {
throw new Error("未获得公钥凭据响应");
}
const response = credential.response;
const result = await postJSON("/api/authentication/verify", {
id: credential.id,
rawId: toBase64url(credential.rawId),
type: credential.type,
clientExtensionResults: credential.getClientExtensionResults(),
response: {
clientDataJSON: toBase64url(response.clientDataJSON),
authenticatorData: toBase64url(response.authenticatorData),
signature: toBase64url(response.signature),
userHandle: response.userHandle === null
? null
: toBase64url(response.userHandle),
},
});
// 只有服务器确认通过后,才显示登录成功或跳转。
return result;
}userHandle 允许为 null,尤其在已预先识别账户的某些流程中。序列化时要保留 null,不要把它编码成字符串 "null"、空字符串或全零字节。服务端根据本次登录路径,按规范检查其是否必须存在以及是否匹配。
上面的接口名称与完整示例一致。认证之后的会话 Cookie 应由服务器设置,JavaScript 返回的 {verified:true} 本身不是受保护资源的访问凭证。
服务器验证清单:每项都有关联目的#
以下是对 §7.2 的学习性归纳,生产代码应使用成熟验证器完成数据解析和密码学校验,并在其外层实现正确的业务状态管理:
- 检查响应结构、类型、大小与编码,确认对应一个本服务器发起且尚未过期的认证流程。
- 若曾下发非空
allowCredentials,确认返回 ID 在列表中;从数据库找到凭据、公钥及其账户关系。 - 验证账户识别规则,包括预期账户归属和需要时的
userHandle一致性。 - 解析
clientDataJSON,要求类型是webauthn.get;challenge 与服务器保存值一致;origin 属于精确允许列表;根据部署策略处理跨来源上下文。 - 验证
authenticatorData.rpIdHash等于预期 RP ID 的 SHA-256;检查 UP,以及策略要求时的 UV。 - 处理请求的扩展结果,使用该凭据登记的公钥和算法,验证
authenticatorData || SHA-256(clientDataJSON)上的签名。 - 按计数器与现代凭据的备份能力策略处理风险及状态更新;原子消费挑战,完成必要记录,然后建立会话。
“原子消费”是为了防止两个并发请求使用同一挑战都通过。短有效期和服务端签名的 token 也不能自动保证一次性;若采用无状态挑战,需要另行解决重放窗口与消费状态。
关于签名计数器#
signCount 可辅助发现某些凭据克隆或状态异常,但不能简单当成“必须从 1 开始、每次严格加一”的业务计数。Level 2 已允许不实现计数的认证器使用零;当新旧值均为零时,不能因此认定克隆。
对于支持计数的情况,异常可作为风险信号;是否拒绝、要求补充验证或记录风险,需要结合认证器行为和业务策略。现代同步型通行密钥还应结合 Level 3 的备份语义,避免机械套用旧式硬件密钥假设。英文原文 §6.1.1 Signature Counter Considerations · Level 3 认证器数据
条件式界面:现代补充#
条件式 mediation 可让通行密钥出现在登录输入框的自动填充选择中。它属于本书的现代补充,不是你提供的 Level 2 示例流程。通常使用带 autocomplete="username webauthn" 的输入框、可发现凭据,以及 mediation: "conditional" 的请求,并先检测能力。
const canUseConditionalUI =
window.PublicKeyCredential &&
typeof PublicKeyCredential.isConditionalMediationAvailable === "function" &&
await PublicKeyCredential.isConditionalMediationAvailable();功能检测成功只表示当前环境报告支持此能力,不证明账户已有通行密钥,也不保证每一种认证器都支持。实际调用仍需服务器挑战、正确序列化和完整验证;保留普通按钮登录路径。英文原文 Level 3 条件式 mediation
取消、重试与错误提示#
页面切换登录方式或用户离开时,可用 AbortController 取消尚未完成的浏览器请求;不要同时挂起多个相互竞争的 get()。取消之后,服务器仍要按自己的过期 / 消费策略处理挑战。英文原文 §5.6 Abort Operations
把“取消 / 超时”“验证失败”“不支持”分别记录便于排错,但对外提示应避免暴露账户是否存在、公钥细节或内部验证错误。重试应获取新的挑战,不要将旧响应再次提交。
学习检查#
- 把上一次成功响应原样重放,服务器是否拒绝?
- 使用另一账户的有效凭据,是否会被错误登录到当前预期账户?
- 修改 origin 或 RP ID,签名或上下文校验是否会失败?
- 要求 UV 时,响应缺少 UV 位是否被拒绝?
- 在验证接口成功之前,页面是否就已经显示“已登录”?
通过正常路径仅说明功能能够工作;这些反向检查才能说明关键安全约束确实在服务器生效。
CHAPTER 06
服务端验证:安全判断发生在哪里
浏览器弹出系统窗口、用户完成指纹验证,都不代表你的网站已经确认登录。最终决定来自服务器:使用已保存的公钥,验证本次响应,并确认它属于正确的操作与账号。
英文对照:§7.1 注册新凭据 · §7.2 验证认证断言。以下是按开发职责整理的中文释义与工程补充;不是替代原文的逐步规范算法。
把验证分成三层#
| 层次 | 谁负责 | 本地示例如何处理 |
|---|---|---|
| 请求上下文 | 应用 | 固定来源、会话 Cookie、操作类型、一次性 challenge 与有效期 |
| WebAuthn 结构与密码学 | 验证库 | 解析客户端数据、CBOR / COSE、RP hash、标志、证明与签名 |
| 账号授权与状态 | 应用 | 凭据归属、user handle、唯一性、计数更新、成功后建立会话 |
成熟验证库能减少密码学实现错误,但无法知道你的业务账号属于谁。因此,“库返回成功”仍不能代替账号绑定检查。
注册响应需要核对什么#
按 §7.1 原文阅读时,重点追踪:
- 客户端数据的类型是
webauthn.create;challenge、origin 与服务端预期一致。 - 解码证明对象,验证 RP ID hash、UP;要求 UV 时必须检查 UV。
- 公钥算法属于发出的算法集合;扩展结果按使用策略处理。
- 按证明格式验证,并决定其信任结果是否符合 RP 策略。
- 检查凭据 ID 的账号归属与唯一性,再保存公钥等结果。
本示例明确接受 none 证明策略,因此不要求设备厂商证书。它仍校验注册数据;同时也不声称能证明设备型号、私钥处于硬件内或用户的现实身份。
登录响应需要核对什么#
§7.2 原文可按下面的顺序理解:
- 根据凭据 ID 查找已登记的公钥,确认凭据属于目标用户;有允许列表时还要检查列表约束。
- 无用户名登录需要有效的
userHandle,并确认它和凭据归属指向同一账号。 - 检查类型
webauthn.get、challenge、origin、RP ID hash、UP 与所要求的 UV;处理扩展。 - 验证
authenticatorData || SHA-256(clientDataJSON)的签名,评估计数器。 - 只有全部通过后,更新状态并建立登录会话。
|| 在这里表示二进制拼接,不是 JavaScript 的逻辑或。clientDataJSON 要使用响应里的原始字节计算哈希,不能先解析 JSON 再重新序列化。
对照可运行代码#
配套服务端源文件使用以下调用进行登录验证;API 已按锁定版本 @simplewebauthn/server 14.0.3核对:
const result = await verifyAuthenticationResponse({
response: req.body,
expectedChallenge: pending.challenge,
expectedOrigin: 'http://localhost:3000',
expectedRPID: 'localhost',
credential, // 从服务端存储取出的 id、publicKey、counter 等
requireUserVerification: true,
});
if (!result.verified || !req.session.active) {
throw new Error('认证验证未通过');
}
credential.counter = result.authenticationInfo.newCounter;credential 不是客户端提交的公钥对象。攻击者若能同时指定“要验证的签名”和“拿来验签的公钥”,就可以用自己的钥匙证明自己提交的数据。
一次性 challenge 为什么要先消费#
示例在进入异步验证库之前,先同步取出并删除当前会话的待验证操作:
function consumeChallenge(session, kind) {
const pending = session.pending;
delete session.pending;
if (!session.active || !pending || pending.kind !== kind ||
pending.expires <= now()) {
throw new Error('挑战缺失、已使用或已过期;请重新开始');
}
return pending;
}这样同一响应不能反复尝试,失败也必须重新申请 challenge。服务端还把待验证数据绑定到不透明会话 ID,注册操作保存服务端生成的账号 ID;请求体里没有可任意指定账号的字段。
示例只支持单进程。共享数据库、多个 Worker 或多个容器不能依靠内存删除实现相同保证,需要数据库的原子消费,并为凭据状态更新处理并发。可继续看生产检查。
计数器是信号,不是绝对结论#
不能要求所有认证器每次都返回更大的数字。一直为 0 的计数器是允许出现的;非零计数回退也不能单独证明发生克隆。英文原文 §6.1.1
本示例沿用验证库策略:接受双方都为 0;若任一值非零而新值不大于已存值,验证库会拒绝。验证与更新之间用单进程锁避免交错。这个策略是示例的明确选择,真实系统应结合认证器类型、并发情况和风险政策设计处置方式。SimpleWebAuthn 官方说明
下一步:运行完整示例,再回来看每个检查分别在哪里执行。
CHAPTER 07
完整示例:本地注册与无用户名登录
下载并运行一套完整的 Node.js 示例,从发出 challenge、调用浏览器 API,到服务器验签和建立会话。它创建独立的匿名学习账号,无需邮箱或密码,也不会给输入的任意用户名绑定凭据。
下载完整示例 ZIP · 查看运行说明 · 服务端源代码 · 浏览器代码 · 自动测试
这是在电脑本地运行的实验。本站 Pages 服务提供文档与源码下载;线上文档页不运行认证服务,也不保存读者的通行密钥记录。
运行条件与命令#
需要 Node.js 22 或更高版本,以及支持 WebAuthn 的浏览器和能执行本地用户验证的认证器。实际是否出现指纹、面容、PIN、安全密钥或手机选项,取决于浏览器、系统和设备配置。
ZIP 解压后,进入含有 package.json 的 examples 目录;项目源码中的目录也叫 examples:
cd examples
npm ci
npm test
npm start浏览器打开 http://localhost:3000。示例固定校验这个 origin 和 localhost RP ID,因此不要替换为 127.0.0.1、局域网 IP 或别的端口。端口被占用时先停止占用服务。Node.js 版本下限依据 SimpleWebAuthn 官方文档;安装文件锁定 @simplewebauthn/server 14.0.3 和 Express 5.3.0,便于复现。
按这四步练习#
- 点击“创建实验账号与通行密钥”,完成系统提示。页面显示匿名账号和凭据元数据。
- 点击“退出当前账号”。这会替换会话,保留服务端内存中的凭据记录。
- 点击“用通行密钥登录”,选择刚才创建的凭据并完成验证。
- 对照登录前后的账号 ID;应回到同一个实验账号。可以在浏览器开发者工具的 Network 面板查看四个协议接口。
进程结束后,账号、公钥和 challenge 全部清空;设备上的通行密钥可能仍在。重启服务后旧凭据会找不到对应账号,可在设备的密码 / 通行密钥管理器中删除该实验记录,再重新注册。
文件职责#
| 文件 | 阅读重点 |
|---|---|
server.js |
challenge 生命周期、服务端验证、账号归属、会话与计数更新 |
public/app.js |
按钮交互、两个原生 WebAuthn API、发送 JSON 响应 |
public/browser.js |
JSON 中 base64url 与 WebAuthn 二进制字段转换 |
public/index.html / style.css |
本地实验页面,无额外构建步骤 |
server.test.js |
软件生成 ES256 密钥,测试真实验签与拒绝路径 |
package-lock.json |
固定本次可复现的依赖树 |
四个接口如何串起来#
| 接口 | 服务器做什么 | 浏览器接着做什么 |
|---|---|---|
POST /api/registration/options |
分配随机账号 ID,生成并保存注册 challenge | 转换二进制字段,调用 create() |
POST /api/registration/verify |
消费 challenge,验证后保存账号与凭据 | 显示注册成功 |
POST /api/authentication/options |
生成并保存登录 challenge,不限制凭据列表 | 调用 get(),让用户选择账号 |
POST /api/authentication/verify |
验证归属与签名,更新计数并轮换会话 | 显示已登录账号 |
所有 POST 都要求本地 origin 和 JSON 内容类型。HttpOnly、SameSite=Strict 的 Cookie 保存不透明随机会话 ID;challenge 保留在服务器中。注册时每个浏览器会话只拥有自己的待创建账号,无法指定已有账号来添加公钥。
注册代码讲解#
服务端创建注册选项:
const options = await generateRegistrationOptions({
rpName: 'Passkey 中文学习实验',
rpID: 'localhost',
userID: Buffer.from(userId, 'base64url'),
userName: `learner-${userId.slice(0, 8)}`,
challenge: randomBytes(32),
attestationType: 'none',
authenticatorSelection: {
residentKey: 'required',
userVerification: 'required',
},
supportedAlgorithmIDs: [-7, -257],
});userId 是服务器产生的 32 字节随机标识。residentKey: 'required' 使示例能够采用可发现凭据的无用户名登录;userVerification: 'required' 同时在服务端验证中强制执行。库会生成适合 JSON 的结构,并补充 requireResidentKey 与 credProps 等选项;参数解释见注册详解。
浏览器把选项转换后,创建凭据并提交结果:
const options = await request('registration/options');
const credential = await navigator.credentials.create({
publicKey: registrationOptions(options),
});
if (!credential) throw new Error('没有创建凭据');
return request('registration/verify', credentialJSON(credential));request() 是示例 app.js 中的同源 POST 辅助函数;registrationOptions() 和 credentialJSON() 来自 browser.js。上面的片段来自完整程序,需要在其上下文中阅读。
无用户名登录如何确认账号#
服务端发出的请求没有 allowCredentials 限制。用户选中凭据后,服务器先根据凭据 ID 查记录,再检查 response.userHandle 是否与记录中的随机账号 ID 相等,最后使用已保存的公钥验证签名:
const credential = credentials.get(req.body.id);
if (!credential || req.body.response?.userHandle !== credential.userId) {
throw new Error('凭据与账号不匹配');
}查到记录不代表已登录。此处之后仍要验证 challenge、origin、RP ID、UP / UV、签名与计数器,成功后才轮换会话。已登录用户点注册按钮会被阻止;想创建另一个独立实验账号时先退出。
测试验证了什么#
npm test 使用 Node.js 自带测试运行器,无需额外测试框架。测试生成软件认证器响应,由真正的验证库解析 CBOR、处理 COSE 公钥并验证 ES256 签名;覆盖以下行为:
- 有效注册、退出、登录,以及成功后拒绝旧响应重放。
- 失败后 challenge 失效、操作类型约束、跨会话不可使用、五分钟过期。
- 篡改 challenge、origin、RP ID、UV 标志、签名或 user handle 均不能登录。
- 非零计数回退被拒绝,始终为零的计数器可以多次登录。
- Host / HTTP Origin 限制、请求频率上限,以及浏览器二进制转换。
软件响应测试能验证后端路径,不代替真实设备、系统 UI、同步提供方与跨设备流程测试。人工练习时仍应完成上面的四步;上线前的验证范围见生产检查与排错指南。
实验边界#
示例只监听本机,最多存放 500 个凭据、1,000 个会话;同一 IP 每分钟最多 120 个 API 请求。会话一小时过期,challenge 五分钟过期;客户端 timeout: 60000 是另一项提示。
它没有持久化、多实例事务、真实身份验证、恢复、添加第二个凭据、删除凭据或线上 HTTPS 会话配置。将它移到服务器时,不能仅把 localhost 替换成域名就视为生产接入。继续阅读生产系统设计。
CHAPTER 08
现代 Passkey 补充:同步、条件式 UI 与能力检测
本章明确区分历史规范与后续实践:前面的核心协议以 WebAuthn Level 2 为主;这里补充 Passkey 生态和 Level 3 内容。截至本次核对的 2026 年 10 月 16 日,W3C 的 Level 3 发布版标示为 2026 年 8 月 25 日 Recommendation。标准发布并不等于每个浏览器已实现全部功能。
同步改变的是凭据可用性#
Passkey 可以保存在单个设备,也可以由凭据提供方安全同步到其他设备。对网站而言,核心仍是登记公钥、验证签名;“可发现”描述如何选择凭据,“同步”描述凭据能否跨设备可用,两者不是同一个维度。FIDO Alliance:Passkeys
不要将“用了平台认证器”推导为“已同步”,也不要将“安全密钥”推导为“用户无需 PIN”。注册策略应表达实际需要:可发现性用 residentKey,本地用户验证用 userVerification。选择具体设备、提供方和恢复方式则由产品目标及用户环境共同决定。
跨设备认证也不等于同步。用户可能在电脑上发起登录,再用手机中的凭据完成认证;网站并没有因此把私钥复制到该电脑。具体选项与提示由浏览器和平台决定,应保留取消与替代路径。
BE 与 BS:两个备份标志#
Level 3 为认证器数据补充了备份语义:
| 标志 | 含义 | 不应推导出的结论 |
|---|---|---|
| BE,Backup Eligibility | 凭据是否具有可备份资格;创建后保持不变 | 不能据此断言用户已经在另一设备上可用 |
| BS,Backup State | 当前是否处于已备份状态;可能变化 | 不能证明用户现实身份,也不保证永久可恢复 |
英文原文:Level 3 §6.1.3 Credential Backup State。示例使用验证库给出的 credentialDeviceType 与 credentialBackedUp 保存相关信息;它们适合辅助账户恢复策略和界面提示,不应替代验签。
计数器不能承担完整的克隆检测#
有些认证器一直返回零;不递增也可能来自故障或请求处理乱序,不能单凭一次异常认定克隆。对现代多设备凭据,更不能自行加上一条“所有登录计数必须严格大于零”的规则。风险处置应结合凭据属性和业务情境。Level 3 §6.1.1 Signature Counter Considerations
配套示例直接使用已锁定版本验证库的策略:零对零通过,非零回退拒绝;在单进程内为读、验、写加锁。若你的生产政策不同,应选择并审查支持相应行为的库或明确实现路径,不能为了绕过错误而把数据库计数器无条件清零。
条件式 UI:在输入框中选择通行密钥#
条件式 UI(conditional mediation)让登录页的自动填充菜单显示可用通行密钥。页面预先挂起 get() 请求;用户选择凭据后才完成流程。它是按钮登录之外的增强,需先检测能力,并保留按钮登录入口。web.dev:Passkey 自动填充指南
输入框需要 webauthn 自动填充标记,放在 token 列表最后:
<label for="username">账号</label>
<input id="username" name="username"
autocomplete="username webauthn">下面是在本地示例基础上扩展的说明片段;默认下载示例只实现按钮登录,不会自动发起条件式请求。它复用 browser.js 的 JSON 转换函数:
import { authenticationOptions, credentialJSON } from './browser.js';
const controller = new AbortController();
async function conditionalLogin() {
const available = window.PublicKeyCredential &&
await PublicKeyCredential.isConditionalMediationAvailable?.();
if (!available) return; // 保留已有按钮登录
const optionResponse = await fetch('/api/authentication/options', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: '{}',
});
if (!optionResponse.ok) throw new Error('无法开始登录');
const options = await optionResponse.json();
const credential = await navigator.credentials.get({
publicKey: authenticationOptions(options),
mediation: 'conditional',
signal: controller.signal,
});
if (!credential) return;
const verification = await fetch('/api/authentication/verify', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(credentialJSON(credential)),
});
if (!verification.ok) throw new Error('验证失败,请重新开始');
// 现在可以刷新账户页面;不能仅根据 get() 返回值显示已登录。
}
conditionalLogin().catch((error) => {
if (error.name !== 'AbortError' && error.name !== 'NotAllowedError') {
console.error('条件式登录未完成', error);
}
});
// 用户点击显式登录 / 注册按钮时,先调用 controller.abort(),
// 再申请新的 options,避免与挂起请求争用同一 challenge。挂起时间可能超过服务器的 challenge 有效期。接入时应设计重新申请与重试流程;不要无限延长 challenge 以适配长时间打开的标签页。一个页面也应协调好注册、条件式请求和显式登录,避免多个流程互相覆盖。
若使用 @simplewebauthn/browser,当前文档中的封装形式是 startAuthentication({ optionsJSON, useBrowserAutofill: true }),仍需合适的输入框和服务端验证。不要混用旧文章中的位置参数签名。SimpleWebAuthn Browser 官方文档
新版 JSON API 与渐进增强#
Level 3 增加了创建 / 请求选项的 JSON 解析和凭据 JSON 序列化能力。是否采用它们应逐项检测;没有能力时仍可使用示例中的显式转换,不要依据浏览器名称或版本字符串猜测。
const publicKey = typeof PublicKeyCredential.parseRequestOptionsFromJSON === 'function'
? PublicKeyCredential.parseRequestOptionsFromJSON(optionsJSON)
: authenticationOptions(optionsJSON);这些方法改变数据转换方式,没有取消服务端验证或改变账号授权边界。Level 3:PublicKeyCredential 接口
继续阅读:从示例到生产系统。
CHAPTER 09
从学习示例到生产系统
Passkey 解决凭据证明问题;账号归属、会话、恢复和凭据管理,仍然是应用自己的责任。本章把规范中的安全与隐私要求整理成开发检查项。
英文原文:§13.4 RP 的安全考量 · §14.6 RP 的隐私考量 · §15 无障碍考量。以下架构与 SQL 为学习补充。
首先保护“添加凭据”这一步#
攻击者不一定要伪造签名。如果他能给受害者的账号绑定自己的 Passkey,以后就能正常签名登录。因此,添加凭据必须绑定到服务端已经确认的账号身份。
给现有账号添加凭据时,从经过认证的会话取账号 ID,视风险要求近期重新认证;不要直接相信请求体里的 userId、邮箱或用户名。新用户注册则先完成你的账号创建和身份归属流程,再把新凭据关联进去。验证码认证只能证明该次验证对应的控制权,不能自动替代所有高风险操作的身份要求。
配套示例采用独立的随机演示账号,让你练习协议;它没有实现邮箱身份验证,也不允许靠输入已有用户名给别人添加凭据。
Challenge 的完整生命周期#
§13.4.3 的中文释义:注册和认证的 challenge 必须在 RP 信任的环境中随机生成;返回值必须与发出的值匹配。不要依靠客户端维护这项约束。规范建议至少 16 字节,以提供足够的不可预测性。
实际开发可以生成 32 个密码学随机字节,并为每次操作保存以下上下文:
| 字段 | 作用 |
|---|---|
challenge |
关联这次响应与服务端发起的操作 |
sessionId 或不透明事务标识 |
防止跨会话挪用 |
ceremony |
区分注册和登录,不能相互替换 |
accountId |
注册绑定当前账号;已知账号登录则绑定目标账号 |
expiresAt |
限制有效时间;页面上的 timeout 不是服务端过期校验 |
| 固定的 origin、RP ID、UV 策略 | 验证时使用可信配置,不能从响应反推 |
应以原子操作消费 challenge,避免两个请求同时通过。下面是 PostgreSQL 的设计示意,不是配套示例的运行依赖:
DELETE FROM webauthn_challenges
WHERE transaction_id = $1
AND session_id = $2
AND ceremony = 'authentication'
AND expires_at > now()
RETURNING challenge, account_id;取不到记录就拒绝;取到以后,无论本次验证成功或失败,都不能再次使用。失败的用户重新申请一个 challenge。登录成功后的凭据计数更新也要处理并发,不能让旧响应覆盖新状态。
不要把 challenge 当作第二份密码:它可以经过浏览器,但必须不可预测、有上下文、限时且只能用一次。对签名验证、安全标志和 origin 的检查仍然必不可少。
数据库实际保存什么#
一个账号通常对应多条凭据。以下为关系模型示意:
CREATE TABLE passkey_credentials (
credential_id TEXT PRIMARY KEY,
account_id UUID NOT NULL REFERENCES accounts(id),
public_key BYTEA NOT NULL,
sign_count BIGINT NOT NULL DEFAULT 0,
transports JSONB NOT NULL DEFAULT '[]',
device_type TEXT,
backed_up BOOLEAN,
label TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
last_used_at TIMESTAMPTZ
);
CREATE INDEX passkey_account_idx ON passkey_credentials(account_id);credential_id 用统一的 base64url 文本编码,公钥保存验证库给出的字节。还应按所用库保存它要求的算法、备份状态等元数据。transports 是交互提示,不是可信的身份断言。label 是用户起的名字,也不能据此判断设备可信度。
账号表另存稳定、随机且无个人信息的 user_handle,长度不得超过 64 字节。一个账号的多个凭据使用同一个 user handle。不要把邮箱、用户名或可猜测的无盐哈希塞进 user.id。§14.6.1 原文
服务器不保存 Passkey 私钥,也不需要收到用户的指纹、面容或设备 PIN。现代凭据提供方可能在其自己的安全体系中同步私钥;这不等于你的 RP 服务端获得私钥。
RP ID 是长期的身份边界#
RP ID 不带协议、端口或路径;origin 则包括协议、主机以及非默认端口。例如 https://login.example.com:8443 是 origin,example.com 可以是符合域名规则的 RP ID。服务端必须分别验证它们。RP ID 定义
把生产、预发布、本地环境分开配置。不要使用 endsWith('example.com') 自制 origin 白名单,因为 evil-example.com 也满足这个字符串条件。使用完整、明确的 origin 列表,并让验证库完成协议校验。
已有凭据与注册时的 RP ID 绑定。域名迁移前必须设计过渡和重新注册流程,不能修改数据库中的一个字符串就指望旧凭据跟随迁移。Cloudflare Pages 的预览地址也可能与正式地址不同;若以后将示例做成真正线上应用,需要以稳定域名规划 RP ID。本文发布的 Pages 站点仅承载文档与源码下载。
会话仍然需要保护#
验证成功后,由服务器签发或轮换应用会话。生产 Cookie 通常使用 Secure、HttpOnly、适当的 SameSite、明确的有效期和受控作用域。退出时销毁服务端会话,敏感操作校验 CSRF 或来源,并限制尝试频率。
Passkey 抗钓鱼能力依赖正确的域绑定和验证。它不会自动修复你站点的 XSS、被盗会话、越权 API 或错误的账号恢复流程。浏览器调用成功只代表拿到了响应,不代表用户已经通过你的服务端认证。
计数器是风险信号#
L2 描述了签名计数器,用于发现可能的凭据克隆或设备异常;它也允许不支持计数的认证器始终返回零。现代同步 Passkey 进一步要求考虑多设备场景。不能把所有凭据的计数都假定为严格递增。
保存验证库返回的计数和备份信息;结合凭据类型、并发、供应商行为和业务风险制定异常策略。不要为了兼容而关闭签名、challenge 或 origin 校验。本文示例沿用验证库的计数检查,遇到异常拒绝并重新发起;生产系统需明确自己的风险处置策略。L2 §6.1.1 · L3 §6.1.1
恢复流程决定整体安全下限#
§13.4.6 的中文释义:L2 本身没有定义私钥备份或在认证器之间共享私钥的协议;RP 应允许并鼓励用户为同一账号注册多个凭据。这是 2021 年规范的语境,不能据此推断今天所有 Passkey 都不可同步。
生产系统应支持多个凭据、可识别的名称、最近使用时间、逐个撤销,以及丢失全部凭据后的恢复。删除最后一个凭据时,先确认还有可用的恢复或登录途径。删除服务器记录会阻止该凭据登录本站,但通常不会自动删除设备或密码管理器里的副本。
恢复流程要与账号风险相称。一个容易被接管的邮箱恢复入口,仍可能绕过很强的 Passkey 登录。恢复成功、添加或删除凭据时,应记录审计事件并按产品政策通知用户。
隐私、可用性与验证#
用户名存在性、allowCredentials 的数量和长度都可能泄露账号信息。对不存在账号与验证失败给出一致的外部错误;需要时采用无用户名的可发现凭据登录。把精确原因留给受控的服务端日志,不记录原始会话令牌、完整响应或生物信息。§14.6.2 · §14.6.3
上线前至少验证这些行为:
- 正常注册、退出、重新登录;一个账号绑定第二个凭据。
- challenge 过期、重放、跨会话使用,以及注册响应用于登录。
- 错误 origin、RP ID、签名、用户验证标志和凭据归属都被拒绝。
- 同一响应并发提交只成功一次;旧计数更新不会覆盖新数据。
- 用户取消、凭据不可用、没有生物识别时有清楚的提示或替代路径。
- 手机、桌面、外接安全密钥的真实操作,以及键盘和屏幕阅读器使用。
规范建议认证器提供多种用户验证方式,并给用户足够时间完成操作。不要把“人脸或指纹”写成唯一途径,也不要用过短的超时排除行动不便的用户。
学习自测:如果有人成功签名,但返回的是另一个账号的凭据,你的服务端会在哪一步拒绝?如果只能回答“前端不会这么做”,账号归属校验还没有完成。
CHAPTER 10
调试、错误与测试
先确定失败发生在哪一层:选项生成、浏览器调用、JSON 编解码,还是服务端验证。把所有问题归结为“指纹失败”,会掩盖最常见的配置和协议错误。
英文原文:§5 Web Authentication API · §5.6 中止操作 · §11 用户代理自动化。下面的排查表和测试方法是开发补充。
一张表定位常见错误#
| 现象 | 优先检查 | 正确处理 |
|---|---|---|
PublicKeyCredential 不存在 |
浏览器、上下文和能力检测 | 提示当前环境不可用,保留可用登录方式 |
SecurityError |
是否是安全上下文;RP ID 是否符合当前域规则 | 分别检查 location.origin 与服务端固定 RP ID |
NotAllowedError |
用户取消、超时、无可用凭据或策略限制等 | 提供重试入口;不要断言“用户取消”是唯一原因 |
InvalidStateError(注册) |
excludeCredentials 是否匹配已存在的凭据 |
告知用户可能已添加过,允许换用另一个认证器 |
NotSupportedError |
算法列表、认证器能力、选项组合 | 使用受支持算法和适合产品的认证器要求 |
TypeError |
challenge、user.id、凭据 ID 的类型和长度 |
JSON 的 base64url 必须在调用原生 API 前转成字节 |
| 页面成功弹窗,服务端拒绝 | challenge、origin、RP ID、UV、签名和账号映射 | 按验证步骤定位;不能因为弹窗成功而跳过验证 |
| 刷新、并发点击后失败 | challenge 被覆盖、消费或过期 | 禁用重复提交,为新尝试申请新 challenge |
| 同一域名换端口后失败 | expected origin 是否包含正确端口 | 为环境设置完整 origin;RP ID 不包含端口 |
| 开发能用,线上不能用 | HTTPS、正式域名、代理和 Cookie | 独立配置环境,查看真实响应状态与 Cookie |
异常类型可能随平台和具体失败阶段变化;以捕获的错误、请求状态和规范算法共同判断,不能用这张表推断唯一根因。
浏览器诊断片段#
const available = window.isSecureContext && 'PublicKeyCredential' in window;
console.table({
origin: location.origin,
secureContext: window.isSecureContext,
webauthnAPI: available,
});
if (available) {
const platformUV = await PublicKeyCredential
.isUserVerifyingPlatformAuthenticatorAvailable();
console.log('是否有支持用户验证的平台认证器:', platformUV);
}platformUV === false 不代表所有 WebAuthn 登录都不可用,用户还可能使用外接认证器或其他平台提供的途径。也不能由这个布尔值推断用户是否注册过本站凭据。能力检测用于改善界面,不能作为服务端认证依据。§5.1.7 原文
生产环境使用 HTTPS。http://localhost 是浏览器认可的本地开发场景;手机访问电脑的 http://192.168.x.x 并不等价。设备之间联调时,需要可信 HTTPS,并同步调整固定的 origin 与 RP ID。
取消正在等待的调用#
用户切换账号、离开页面或重新发起认证时,可以用 AbortController 结束旧请求:
const controller = new AbortController();
document.querySelector('#cancel').addEventListener('click', () => {
controller.abort();
});
try {
// publicKey 来自服务端,并已把二进制字段解码为 Uint8Array
const assertion = await navigator.credentials.get({
publicKey,
signal: controller.signal,
});
if (!assertion) throw new Error('未返回凭据');
// 接下来序列化并提交服务端验证
} catch (error) {
if (controller.signal.aborted) {
console.log('本次等待已取消,可以重新开始');
} else {
console.error(error.name, error.message);
}
}这是 API 用法片段,要求页面中已有 #cancel 按钮和经过解码的 publicKey。不要复用已经中止的 controller。取消浏览器等待不等于服务端自动销毁 challenge,服务端仍然需要过期和一次性消费策略。
看懂数据,不改写签名输入#
clientDataJSON 是原始 UTF-8 JSON 字节;可解码查看 type、challenge、origin 和 crossOrigin。登录的 type 应为 webauthn.get,注册为 webauthn.create。
// browserResponse 是 navigator.credentials.get() 返回的凭据
const text = new TextDecoder().decode(browserResponse.response.clientDataJSON);
const clientData = JSON.parse(text);
console.log(clientData.type, clientData.origin);本地调试可读字段,验证时却必须以收到的原始字节计算哈希。先解析再 JSON.stringify() 会改变空格、键顺序或转义方式,使签名输入改变。也不要把二进制数据错误地当成十六进制字符串或普通 Base64。
对于完整响应,优先观察字段是否存在、解码后字节数、请求关联 ID 和验证阶段;生产日志不应成为可重放响应或账号标识的集中仓库。§5.8.1 原文
把测试分成三层#
| 层次 | 验证什么 | 无法证明什么 |
|---|---|---|
| 服务端单元与接口测试 | 过期、重放、会话绑定、错误参数、库的验证拒绝 | 真实浏览器弹窗和设备行为 |
| 虚拟认证器端到端测试 | 从注册、存储到登录的真实签名与 API 交互 | 生物识别、蓝牙、硬件和设备同步体验 |
| 真实设备测试 | 平台交互、取消、恢复、跨设备可用性 | 所有未来系统或浏览器版本都一致 |
L2 §11 定义虚拟认证器自动化模型。浏览器开发工具和自动化工具可以据此模拟认证器;这适合持续集成,但应另做真实设备验证。自动化中把“用户验证成功”设为真,也只是在模拟 UV,不能声称测试过真实指纹。
配套源码含可运行测试;源码 README 记录验证范围。将它作为你的测试起点,然后补充账号绑定、恢复、撤销和部署域名相关的应用测试。
遇到失败时的记录模板#
环境:浏览器 / 操作系统 / 认证器类型
操作:注册 / 有用户名登录 / 无用户名登录
配置:固定 RP ID、预期 origin(不含令牌)
阶段:options / create|get / serialize / verify
外部表现:异常 name、HTTP 状态、请求关联 ID
服务端原因:challenge expired / origin mismatch / ...
复现:是否稳定,换设备、重试或刷新后如何变化一次只改变一个变量。先修复域名与编解码错误,再判断认证器兼容性;不要为排查问题临时关掉验证并把配置带到生产。
CHAPTER 11
英文规范导读与章节对照
掌握注册与登录后,回到英文规范核对边界。这份导读保留原有章节与链接,帮助你按问题定位,而不把浏览器和认证器实现算法误当作必须自己手写的业务代码。
主来源:Web Authentication: An API for accessing Public Key Credentials — Level 2,W3C Recommendation,2021 年 4 月 8 日。以下是章节释义与索引;完整定义、算法顺序和要求以英文原文为准。
规范用词如何理解#
§2 Conformance 说明一致性要求。阅读规范时,特别注意这些词:
| 英文 | 中文理解 | 开发时的含义 |
|---|---|---|
| MUST / MUST NOT | 必须 / 不得 | 一致性要求,不能为了让示例通过而忽略 |
| SHOULD / SHOULD NOT | 应当 / 不应当 | 通常遵循;例外需要理解后果和适用条件 |
| MAY | 可以 | 可选能力,不能假定所有实现都有 |
| non-normative | 非规范性 | 示例、背景和解释,不是独立的合规算法 |
同一章节可能同时包含规范性算法、说明和 Note。本文的“建议”若标为开发补充,不应被误引为 W3C 的 MUST。
完整章节导航#
| 原文 | 中文主题 | 对本站开发者最直接的用途 |
|---|---|---|
| §1 Introduction | 简介、用例和 API 示例 | 理解两种仪式与参与者 |
| §2 Conformance | 一致性要求 | 区分 RP、浏览器、认证器各自的要求 |
| §3 Dependencies | 外部依赖 | 找到 Web IDL、HTML、Credential Management 等基础规范 |
| §4 Terminology | 术语 | 澄清凭据、认证器、用户句柄和 RP ID |
| §5 Web Authentication API | 浏览器 API | 查选项、响应类型和错误条件 |
| §6 Authenticator Model | 认证器模型 | 理解认证器数据、公钥、签名和计数器 |
| §7 Relying Party Operations | RP 操作 | 注册与认证验证的核心依据 |
| §8 Attestation Statement Formats | 证明语句格式 | 有设备来源或企业准入需求时深入 |
| §9 WebAuthn Extensions | 扩展机制 | 理解客户端与认证器扩展如何处理 |
| §10 Defined Extensions | 已定义扩展 | 查 credProps、largeBlob、AppID 等 |
| §11 User Agent Automation | 自动化 | 建立虚拟认证器测试 |
| §12 IANA Considerations | 标识符注册 | 实现底层格式或扩展时参考 |
| §13 Security Considerations | 安全考量 | challenge、凭据丢失、证明的局限 |
| §14 Privacy Considerations | 隐私考量 | 用户句柄、账号枚举和跨站关联 |
| §15 Accessibility | 无障碍 | 足够的时间、多种验证方式与提示 |
| §16 Acknowledgements | 致谢 | 原规范贡献者 |
| Index · References · IDL Index | 索引、参考文献和接口 | 精确查定义和类型 |
本文完整保留了规范的阅读入口,但没有逐字翻译所有认证器内部算法、证明格式和 Web IDL。学习与实现本站示例所需的内容,在前面章节按流程展开。
§6:认证器数据的结构#
§6.1 Authenticator Data 定义一段字节序列,而不是 JSON 对象。其开头包含:
| 字节内容 | 长度 | 意义 |
|---|---|---|
rpIdHash |
32 字节 | RP ID 的 SHA-256 哈希 |
flags |
1 字节 | 用户在场、用户验证及后续数据等标志 |
signCount |
4 字节 | 大端无符号签名计数 |
| attested credential data | 可变,可选 | 注册公钥、凭据 ID 和 AAGUID 等 |
| extensions | 可变,可选 | 扩展输出 |
应交给成熟验证库按格式和标志解析。不能简单用“长度大于 37 字节”证明有效。现代 L3 又定义了备份相关标志;不要把 L2 未定义位的解释套在所有新凭据上。
认证断言的核心签名输入为:
authenticatorData || SHA-256(clientDataJSON)这里的 || 表示拼接原始字节。clientDataJSON 中包含 challenge 与 origin,authenticatorData 中包含 RP ID 哈希和用户操作标志。服务端必须既验证签名,也校验这些字段符合自己发出的请求。
§6.5 与 §8:Attestation 证明什么#
Authentication assertion 用已注册的凭据私钥证明本次认证;Attestation 在注册阶段表达新公钥的来源与认证器属性。后者可能涉及设备厂商、证书链和信任策略,并不证明注册者就是某个业务账号的合法主人。
常规公众站点通常可以从 attestation: 'none' 开始,以减少隐私和部署负担。这不等于“跳过注册验证”:仍然需要解析 attestation object,提取公钥,验证 challenge、origin、RP ID、算法、标志和其他必需条件。该选择也不能作为“私钥必定存放在某种硬件”的证据。
| 格式入口 | 中文解释 |
|---|---|
| packed | 通用紧凑证明格式 |
| TPM | 与可信平台模块结构相关的证明 |
| Android Key | Android 密钥证明 |
| Android SafetyNet | L2 中保留的历史格式;不能据此推断服务当前可用性 |
| FIDO U2F | U2F 兼容证明 |
| none | 不提供可验证设备来源的证明语句 |
| Apple anonymous | Apple 匿名证明格式 |
企业若必须限制认证器型号,需要额外的证明校验、可信根与元数据、撤销处理和隐私评估。这比“设置 direct”多出一整套信任决策。§13.4.4 Attestation Limitations
§9 与 §10:扩展是可选能力#
| L2 扩展 | 用途 | 学习时的注意点 |
|---|---|---|
| appid | 兼容旧 FIDO U2F 凭据 | 仅有迁移需求才引入 |
| appidExclude | 注册时排除已有旧 U2F 凭据 | 不替代新凭据的正常注册检查 |
| uvm | 用户验证方法信息 | 可用性和隐私要分别考虑 |
| credProps | 获取凭据属性,例如 rk |
用于了解可发现性;客户端输出不能代替服务器身份验证 |
| largeBlob | 凭据关联的大数据存储 | 非所有认证器支持;不要当作通用应用数据库 |
调用 credential.getClientExtensionResults() 读取客户端扩展输出。某项输出缺失可能意味着未支持或未执行;根据该扩展的定义处理。客户端输出和认证器签名覆盖的扩展输出不是同一信任层次,不能把所有扩展结果都当作已签名事实。
按问题跳转#
CHAPTER 12
来源、版本与译注
本项目是一份面向 Web 开发者的中文学习文档:整理翻译 WebAuthn Level 2 的核心内容,并配上独立讲解与示例代码。它不是 W3C 授权译本,不是完整逐字翻译,也不构成新的技术规范。
整理与链接核对日期:2026 年 10 月 16 日。若中文解释与规范产生歧义,以对应英文版本为准。浏览器能力和第三方库 API 应按各自版本再次核对。
主规范与原始入口#
| 来源 | 版本与用途 |
|---|---|
| 用户提供的英文入口 §1.2.2 Authentication | 完整保留最初链接,含其原始查询参数 |
| WebAuthn Level 2 | W3C Recommendation,2021-04-08;本文主要整理翻译来源 |
| Level 2 固定版本 | 对照历史语境时使用的不可变版本入口 |
| Level 2 Errata | 发布后的勘误;请同时检查规范首页所指向的最新勘误入口 |
| WebAuthn Level 3 | 现代补充来源;核对时首页标示 W3C Recommendation,2026-08-25 |
| Level 3 固定版本 | 本次补充所参考的发布版本 |
| 编辑草案 | 可能持续变化;不能与上述发布版不加区分地引用 |
如何区分文档内容#
**“中文释义”按对应原文重述核心意思,允许调整句式以方便学习。“开发讲解”解释工程上的原因、误区与取舍。“现代补充”**说明 L2 之后的 Passkey 实践或 L3 能力。代码示例用于演示学习,不是规范原文的逐行复刻。
规范中的浏览器内部算法和认证器操作,被整理为职责、数据结构与原文导航;没有声称全部翻译。设备证明格式、扩展和自动化的深层细节请通过章节对照回到原文。
示例与延伸阅读#
- SimpleWebAuthn Server 官方文档:配套示例的服务端验证库及其当前参数。
- SimpleWebAuthn Browser 官方文档:浏览器封装 API、JSON 转换和条件式 UI 的相关说明。
- SimpleWebAuthn 源码:核对版本行为、测试与问题。
- FIDO Alliance:Passkeys:Passkey 术语、生态和背景。
- Chrome for Developers:Passkeys:浏览器端的实现与调试资料。
- Cloudflare Pages Direct Upload:本文静态文档站的部署方式。
本站的学习代码及运行说明可以从完整示例进入。站点本身没有收集、注册或验证读者真实凭据的线上账号系统。
W3C 归属与许可#
本文包含从 Web Authentication: An API for accessing Public Key Credentials — Level 2 复制、翻译或派生的材料;原规范状态为 W3C Recommendation,2021 年 4 月 8 日。
Copyright © 2021 W3C® (MIT, ERCIM, Keio, Beihang). This software or document includes material copied from or derived from Web Authentication: An API for accessing Public Key Credentials — Level 2, https://www.w3.org/TR/webauthn-2/.
保留原作者版权声明:Copyright © 2021 W3C (World Wide Web Consortium)® (MIT (Massachusetts Institute of Technology), ERCIM (European Research Consortium for Informatics and Mathematics), Keio, Beihang). W3C liability、trademark 和 document use 规则适用。原文的规范、状态和完整归属可在英文版首页查看。
现代补充也参考 WebAuthn Level 3。Copyright © 2026 World Wide Web Consortium. This document includes material derived from Web Authentication: An API for accessing Public Key Credentials — Level 3. 参见 W3C document license。
此中文指南用于帮助实现和学习配套软件,不作为替代 W3C 英文规范的技术标准,也不表示得到 W3C 或任何浏览器、认证器厂商的认可。原规范按其许可中列明的条件与免责声明提供。