LIVING WEB MAGAZINE
komugi.小麦

小麦的照片,还有一些生活与工程手记

目录 (9 节)↓
fullstackISSUE 2026-10

为网站接入 Passkey:从 WebAuthn 到会话与恢复

Passkey 接入涉及注册授权、登录验签、一次性 challenge,以及应用自己的会话和恢复机制。这篇从浏览器、认证器和服务端的分工讲起,解释各环节的实现方式与常见取舍。

给网站加上 Passkey 登录按钮后,服务端还要回答几个问题:谁可以绑定新凭据,认证响应属于哪一次请求,验签成功后如何维持登录,以及用户失去所有凭据后如何恢复账户。WebAuthn 负责其中的公钥认证,账户权限和应用会话仍由网站管理。

下面用一个通用 Web 应用说明这条链路。域名、数据模型和代码均为教学示例,便于讨论实现取舍。

1. 网站保存公钥,用户在自己的认证器里授权

一次 Passkey 登录涉及三个角色:

角色 负责的工作 持有的数据
浏览器 调用 WebAuthn API,协调系统选择凭据,把结果发回网站 注册或登录参数、认证结果
认证器 生成密钥,完成本地用户验证,执行签名 凭据私钥及关联信息
服务端,也叫依赖方 RP 发出挑战,验证结果,维护账户和会话 凭据 ID、公钥、生命周期记录

认证器可以是平台提供的能力、密码管理器或安全密钥。指纹、面容和 PIN 用来在本地授权私钥操作,网站收到的是密码学结果,没有理由收集用户的指纹图像。FIDO 的认证规范说明明确了本地生物特征处理的边界。

私钥不传给网站服务器。同步 Passkey 则可能由凭据提供商保护并在设备间同步,设备绑定的 Passkey 留在原设备上。把所有 Passkey 都描述成永远无法离开某一块硬件,会误导恢复设计。FIDO 的企业部署说明区分了这两类凭据。

网站数据库泄露公钥,不足以生成有效登录签名。数据库仍然值得保护,因为攻击者能修改公钥绑定关系时,可能把自己的凭据换进去。读取公钥与篡改账户权限是两种不同风险。

2. 注册是在账户下绑定一把新钥匙

注册要先确定当前请求有权给哪个账户加凭据。在后台系统里,匿名访问者不能因为数据库还没有管理员,就获得首次绑定权限。浏览器能弹出创建 Passkey 的窗口,只证明客户端具备能力。

已有账户添加第一把 Passkey 时,可以沿用站点已建立的可信身份验证流程;新账户则需要先完成站点定义的开户流程。之后添加凭据,通常要求有效会话,并对敏感账户要求近期重新认证。注册参数生成和结果提交都要检查绑定权限,最终写入也要考虑会话在流程中途被撤销的情况。

注册的正常往返如下:

步骤 交互 处理要点
1 浏览器请求注册参数 请求者是否有权给目标账户添加凭据
2 服务端保存挑战并返回注册参数 挑战的有效期、用途和绑定上下文
3 浏览器调用 navigator.credentials.create() 客户端按照返回参数发起注册
4 认证器完成本地验证并创建凭据 是否满足服务端要求,由响应核验确认
5 浏览器提交凭据响应和流程标识 当前绑定权限仍有效,流程上下文一致
6 服务端原子消费挑战并验证响应 挑战、来源、RP ID、算法及验证状态
7 服务端将凭据绑定到账户 凭据唯一性、账户状态和写入结果

服务端给浏览器的参数包括 RP ID、账户显示名称、账户的不透明字节标识、随机 challenge,以及 residentKey 和 userVerification 等要求。浏览器调用 navigator.credentials.create(),认证器完成本地交互并产生新凭据,服务端最后核验注册响应并存储公钥。注册产生的 attestation object 与登录 assertion 是不同的数据结构,不能混用验证函数。Google 的服务端注册指南描述了参数生成、结果核验和凭据存储的对应关系。

面向普通网站的实现可以选择 attestationType: "none",不要求认证器提供可识别设备来源的证明。凭据注册与会话创建也可以分开:注册只负责增加凭据,用户再通过正常登录获取会话。若产品选择注册后直接登录,服务端仍需独立检查签发会话的条件,不能把所有绑定权限都视为完整账户权限。

3. 不输入用户名,需要可发现凭据

注册时的 user.id 是字节序列,后续响应中的 userHandle 用来帮助服务端关联账户。它不等于用于显示的 user.name,也不该装入邮箱或用户名。WebAuthn 的用户标识要求规定它是不透明标识;多用户系统可以在账户创建时生成随机值,持久保存后复用。

可发现凭据允许认证器按照 RP 找出可用账户。未预先识别账户的无用户名登录,应要求 userHandle 存在,并确认它标识的账户拥有返回的凭据 ID。若流程开始前已通过用户名或可信 cookie 识别账户,则响应提供 userHandle 时也要比较其一致性。WebAuthn 的 assertion 核验步骤区分了这两种上下文。前端传来的显示名称不能决定登录身份。

实现无用户名登录时,注册可以要求 residentKey: "required",登录请求不预先限定 allowCredentials 列表,让用户从可用凭据中选择账户。服务端随后根据凭据 ID 查找记录,核对 userHandle 与凭据归属,并检查账户是否仍可登录。若产品同时提供先输入用户名的流程,还需要避免通过不同的提示或响应暴露某个账户是否存在。

4. 登录签名同时绑定挑战和站点上下文

登录时,服务端生成新的 challenge,浏览器用 navigator.credentials.get() 请求认证器执行 assertion。认证器使用已注册私钥签名,服务端用保存的公钥验证,成功后再创建应用会话。MDN 的 WebAuthn 概览介绍了注册与认证两条浏览器调用链。

登录签名的输入可以写成:

signature = Sign(privateKey,
                 authenticatorData || SHA-256(clientDataJSON))

clientDataJSON: type、challenge、origin 等客户端上下文
authenticatorData: RP ID hash、用户状态标志、signCount 等

|| 表示拼接字节。常见说法是给 challenge 签名,但服务端实际验证的是上述组合数据。修改挑战或客户端来源,会改变被验证的数据;服务端还要单独检查这些字段符合自己发起的流程。Google 的认证响应核验说明给出了这一签名输入及核验要素。

服务端应从可信配置和自己保存的挑战中取得验证库需要的预期值,包括 expectedChallenge、expectedOrigin、expectedRPID,并按策略设置 requireUserVerification: true。任意请求 Host 或前端 JSON 都不能直接成为可信来源;多域名应用需要明确的允许列表和租户归属校验。

RP ID 是域名,比如 example.com;origin 是协议、主机和端口的组合,比如 https://example.com。常规浏览器 RP ID 规则允许符合条件的父域名,选择精确主机可以缩小凭据作用范围。MDN 的请求参数说明解释了 RP ID 与来源的关系。迁移域名应单独设计,不能假设旧 Passkey 会自动跟随新域名。

userVerification: "required" 告诉认证器必须完成本地用户验证,服务端也要核验 UV 状态。只在前端设置这个选项,无法约束伪造的提交。验证成功也不告诉网站用户究竟用了指纹还是 PIN,应用只需判断是否满足自己的验证策略。

5. challenge 既要限时,也只能消费一次

一次 challenge 记录通常包含随机挑战、流程标识、用途、浏览器关联信息、账户与会话上下文,以及到期时间。有效期应限制在完成一次交互所需的短时间内。登录、注册和重新验证要区分用途,某一种操作的授权不能直接拿来完成另一种操作。

一种做法是通过短期 HttpOnly flow cookie 关联浏览器与认证流程。它与最终登录会话分开,帮助服务端确认返回的结果属于发起流程的浏览器。这里的 cookie 由应用在普通 HTTP 响应中设置,WebAuthn API 不会自动替网站签发 flow cookie 或 session cookie。SimpleWebAuthn 的 Passkey 指南给出了未登录状态下关联 challenge 的思路。

只按下面的顺序写代码,会遇到并发问题:

读取 challenge
验证还未使用
验证签名
标记已使用

两个请求可能同时读到未使用状态,然后分别签发会话。数据库支持 DELETE ... RETURNING 时,可以用一条条件删除完成匹配和消费。下面采用通用数据模型,时间字段使用可比较的整数时间戳:

-- 示意 SQL:表名和字段仅用于说明流程
DELETE FROM authentication_flow
WHERE flow_id = :flowId
  AND purpose = :purpose
  AND browser_binding = :browserBinding
  AND context_digest = :contextDigest
  AND expires_at > :now
RETURNING challenge;

context_digest 表示服务端绑定的账户、会话等流程上下文,不由客户端自由指定。拿到返回行才继续验签,并发中只有一个请求能取得同一行。正确上下文进入验证后,即使验签失败,这次挑战也已消费,用户需要重新发起。错误浏览器流程无法命中条件,也不会烧掉合法浏览器的挑战。公开入口同时需要限流、请求大小限制和过期记录清理,以免临时 challenge 变成数据库负担。

6. 公钥和计数器要保留验证库需要的形态

凭据公钥通常以 COSE 编码的字节数据进入验证库,不能把它当成随意可替换的 PEM 字符串。数据库可以保存二进制数据,也可以保存 Base64URL 文本,在读取时还原为 Uint8Array。算法解析与密码学核验适合交给成熟的 WebAuthn 库;升级依赖或更换运行环境时,需要检查公钥编码和允许算法的兼容性。

signCount 有助于观察认证器异常,但某些凭据一直返回零,同步凭据也不能一概套用每次严格加一的业务规则。应保存注册与登录核验返回的计数值,由验证库处理其语义,再结合异常信号和产品策略决策。SimpleWebAuthn 的计数器说明解释了零计数器的情况。WebAuthn 的计数器章节指出计数器异常也可能来自故障或请求处理顺序,无法只据此认定凭据克隆。

应用把已保存的 counter 传给验证库,再持久化通过核验的结果。数据库可以通过版本号或旧值匹配防止并发覆盖,这与要求计数器严格递增是两个独立条件。出现并发冲突时,应有明确的重试或重新认证策略;零计数器和同步凭据的行为还需要真设备覆盖。

7. 验签成功后,后台仍需要自己的会话

应用通常需要维护四类数据,是否拆成独立数据表取决于已有的账户系统:

数据对象 主要职责
账户 用户身份、账户状态和权限
凭据 账户关联、公钥、计数器和撤销状态
认证流程 短期挑战、用途和一次性消费条件
会话 会话摘要、有效期、近期验证时间和撤销状态

一种常见做法是在验签通过后生成高熵随机会话 token,数据库只保存摘要,再通过 Set-Cookie 把原始 token 返回浏览器。用于同源应用的会话 cookie 可以设置 HttpOnly、Secure、Path=/,使用 __Host- 前缀且不设置 Domain。SameSite=Strict 适合不依赖跨站会话传递的流程,具体策略需要结合外部跳转和登录体验选择。

会话应有明确的绝对有效期。是否允许续期、是否设置空闲超时,以及是否随凭据撤销而立即失效,都属于应用策略。每次访问受保护 API 时,需要检查当前会话和账户状态,不能因为曾经验签成功就一直信任客户端。

HttpOnly 限制脚本读取 cookie,Secure 约束传输,二者不能阻止已发生的 XSS 在当前页面内代用户调用 API。OWASP 的会话指南解释了这些属性的实际保护范围。

cookie 会随符合条件的请求发送,写操作因此还要防 CSRF。可以结合精确 Origin 校验和会话绑定的 CSRF token,并限制浏览器客户端向哪些来源发送凭据。SameSite 判断的 site 与 origin 不同,兄弟子域也可能被视为同站,所以不能只看这个 cookie 属性。OWASP 的 CSRF 指南讨论了这一限制。

下面是未预先识别账户时的登录流程示意,按无用户名登录要求核对 userHandle。代码省略了 HTTP 参数解析、限流、错误响应和日志处理,辅助函数表达的是应用需要实现的职责:

// 基于 SimpleWebAuthn 13.3.x API 的流程示意
async function verifyLogin(input, flowCookie) {
  const challenge = await consumeOnce(input.challengeId, flowCookie, "login");
  if (!challenge) throw new AuthError("invalid_challenge");

  const key = await findActiveCredential(input.response.id);
  if (!key) throw new AuthError("authentication_failed");
  requireUserHandleMatchesCredential(input.response, key);

  const result = await verifyAuthenticationResponse({
    response: input.response,
    expectedChallenge: challenge.value,
    expectedOrigin: configuredOrigin,
    expectedRPID: configuredRpId,
    requireUserVerification: true,
    credential: {
      id: key.id,
      publicKey: decodeBase64URL(key.publicKey),
      counter: key.counter,
      transports: key.transports,
    },
  });
  if (!result.verified) throw new AuthError("authentication_failed");

  await updateUsageWithConcurrencyGuard(key, result.authenticationInfo);
  const token = await createRevocableSessionForActiveCredential(key);
  return responseWithSecureSessionCookie(token);
}

示例采用 SimpleWebAuthn 13.3.x 的参数形态,接入其他版本时应核对 credential 的结构和结果类型;浏览器侧用 startRegistration({ optionsJSON }) 和 startAuthentication({ optionsJSON }) 完成编码转换与系统调用,签发会话由应用自行完成。浏览器文档给出了对应接口。

8. 恢复入口决定设备丢失后如何保住账户

允许用户注册多个凭据,可以减少单个设备丢失带来的影响。新增、移除凭据都属于敏感操作,适合要求近期重新认证。删除最后一个可用凭据前,需要确认账户仍有可用的登录或恢复方式;移除凭据时,还要按产品策略撤销关联会话。

全部凭据不可用时,恢复流程必须重新建立足够可信的账户归属证明。具体方式取决于产品风险,可以使用事先建立的恢复凭证或独立身份核验。恢复授权应限制用途和有效期,仅允许完成指定的凭据替换操作,避免直接赋予完整会话权限。

旧凭据、旧会话的撤销与替代凭据的写入需要保持一致。关系数据库可以通过事务完成这组状态变更,让失败的恢复保留原状态。完成替换后,可以要求用户使用新 Passkey 正常登录,再签发应用会话。

恢复方式的强度会影响账户整体安全。能替换凭据的恢复流程,应纳入与普通登录相同的威胁分析;高价值账户还可以增加等待期、独立通知或人工复核。等待期会影响用户恢复速度,人工复核也有运维成本,需要明确适用范围。

如果站点已有其他身份系统,迁移时需要明确每个系统负责的账户、认证和授权范围。普通登录页面必须对目标用户可达,绑定与恢复操作则继续检查各自的授权条件。面向浏览器用户和程序调用的认证方式也应分别设计。

9. 验收覆盖从认证响应到真实设备的整条链路

模拟认证器可以生成可验证的密码学响应,适合检查算法、公钥编码、挑战匹配和重放处理。系统凭据选择界面、跨设备认证和同步行为仍需要在目标设备与浏览器上验证。自动化测试通过后,可以按下面几组场景检查应用行为:

场景 需要确认的结果
正常注册与登录 支持的设备能够创建凭据,登录后得到正确账户的会话
挑战与来源异常 重放、过期、错误用途、错误 RP ID、错误 origin 均被拒绝
本地验证与账户归属 缺少所需 UV、错误 userHandle、凭据与账户不匹配时无法登录
并发和取消 同一挑战只消费一次,冲突或用户取消后可以重新发起流程
凭据管理 添加和删除检查近期认证,撤销按策略影响关联会话
账户恢复 恢复授权不能越权,部分失败不会留下新旧状态混杂的账户
会话生命周期 过期、退出、全设备退出和账户停用都能阻止后续受保护请求

错误日志需要能够区分参数校验、挑战消费、密码学核验和会话创建失败,同时避免记录完整认证响应、原始会话 token 或恢复凭证。对用户则提供可以执行的下一步,比如重新开始认证、选择其他凭据或进入已配置的恢复流程,避免把内部校验细节暴露为账号探测信号。

实际接入时,可以先实现一条完整的注册和登录链路,再逐项加入凭据管理、会话撤销和恢复流程。每加一种能够改变账户访问权限的操作,都沿着授权检查、一次性挑战、原子状态更新和会话影响检查一遍。