简短回答:通行密钥(passkey)是一种 WebAuthn 可发现凭证:由认证器(手机、笔记本电脑的安全硬件、密码管理器或安全密钥)为某一个网站(即依赖方)创建的密钥对。注册时,服务器收到一个公钥;登录时,服务器收到对其挑战值的签名。对每个响应,服务器都必须检查挑战值、源、RP ID 和用户验证标志。请求 residentKey: "required" 和 userVerification: "required",提供 -8、-7 和 -257 作为算法;除非你是需要限制认证器型号的企业,否则把证明保持为 "none";并且要预计到大多数通行密钥会在设备之间同步。
一张表看懂基本模型
| 术语 | 对你的代码意味着什么 |
|---|---|
| 依赖方(RP)和 RP ID | 你的网站。RP ID 是一个域名,例如 example.com;凭证在该域名及其子域名上有效,在其他任何地方都不能使用 |
| 可发现凭证 | 与用户句柄一起存储在认证器上,因此登录时不需要用户名。WebAuthn Level 3 把“passkey”列为它的另一个名称 |
用户句柄(user.id) | 你的不透明账号 ID,最长 64 字节,登录时返回。绝不能是电子邮件地址 |
| 用户在场(UP)和用户验证(UV) | UP:有人触摸或确认过。UV:认证器检查了 PIN 或生物识别。二者都是认证器数据中的标志 |
| 可备份(BE)和已备份(BS) | 可以同步或已经同步到其他设备的凭证:即同步通行密钥,规范中称为“多设备凭证” |
| 证明和 AAGUID | 一条可选的证书链,说明密钥由哪种认证器型号生成;AAGUID 标识该型号 |
认证器数据中的标志位分别为:第 0 位 UP,第 2 位 UV,第 3 位 BE,第 4 位 BS,第 6 位 AT(包含已证明凭证数据),第 7 位 ED(扩展)(WebAuthn Level 3 §6.1)。WebAuthn Level 3 于2026年8月25日成为 W3C 推荐标准。
服务器:两个流程
下面是一个用 @simplewebauthn/server 14.0.3 实现的完整依赖方。为了简短,数据存放在内存中;在生产环境中,这三个映射是数据库表,挑战值则保存在用户的服务端会话中。
// Relying party: registration and sign-in ceremonies with @simplewebauthn/server 14.0.3.
import {
generateAuthenticationOptions,
generateRegistrationOptions,
verifyAuthenticationResponse,
verifyRegistrationResponse,
} from "@simplewebauthn/server";
const rpID = "localhost"; // the site's registrable domain in production, e.g. "example.com"
const origin = "http://localhost:3000"; // every origin allowed to use this RP ID
const users = new Map(); // userName -> { id, credentials: [] }
const credentials = new Map(); // credential ID -> { userName, publicKey, counter, transports }
const pending = new Map(); // ceremony ID -> expected challenge, single use
export async function registrationOptions(userName, { attestation = "none" } = {}) {
const user = users.get(userName) ?? { id: crypto.getRandomValues(new Uint8Array(16)), credentials: [] };
users.set(userName, user);
const options = await generateRegistrationOptions({
rpName: "Example",
rpID,
userName,
userID: user.id,
attestationType: attestation,
authenticatorSelection: { residentKey: "required", userVerification: "required" },
supportedAlgorithmIDs: [-8, -7, -257], // EdDSA, ES256, RS256
excludeCredentials: user.credentials.map((id) => ({ id, transports: credentials.get(id).transports })),
});
pending.set(`reg:${userName}`, options.challenge);
return options;
}
export async function finishRegistration(userName, response) {
const expectedChallenge = pending.get(`reg:${userName}`);
pending.delete(`reg:${userName}`);
const { verified, registrationInfo: info } = await verifyRegistrationResponse({
response,
expectedChallenge,
expectedOrigin: origin,
expectedRPID: rpID,
requireUserVerification: true,
});
if (!verified) throw new Error("registration not verified");
const { credential } = info;
credentials.set(credential.id, { userName, publicKey: credential.publicKey, counter: credential.counter, transports: credential.transports });
users.get(userName).credentials.push(credential.id);
return info;
}
export async function authenticationOptions() {
// Empty allowCredentials: the authenticator offers its discoverable credentials (passkeys).
const options = await generateAuthenticationOptions({ rpID, userVerification: "required" });
pending.set(`auth:${options.challenge}`, options.challenge);
return options;
}
export async function finishAuthentication(response, challenge) {
if (!pending.delete(`auth:${challenge}`)) throw new Error("unknown or used challenge");
const stored = credentials.get(response.id);
if (!stored) throw new Error("unknown credential");
const { verified, authenticationInfo } = await verifyAuthenticationResponse({
response,
expectedChallenge: challenge,
expectedOrigin: origin,
expectedRPID: rpID,
credential: { id: response.id, publicKey: stored.publicKey, counter: stored.counter, transports: stored.transports },
requireUserVerification: true,
});
if (!verified) throw new Error("authentication not verified");
stored.counter = authenticationInfo.newCounter;
return { userName: stored.userName, ...authenticationInfo };
}
两个验证调用各检查什么,以及每项检查为什么重要:
- 挑战值。随机、一次性,并绑定到当前流程。带着旧挑战值的响应就是重放。
- 源。浏览器把页面的源写入
clientDataJSON,认证器对它的哈希签名。把它与你的源列表比较,正是通行密钥能够抗钓鱼的原因:仿冒网站产生的响应带的是它自己的源。 - RP ID 哈希。认证器数据以 RP ID 的 SHA-256 哈希开头;为其他网站创建的凭证无法用在你的网站上。
- 标志。UP 必须置位;当你只依赖通行密钥本身完成登录时,UV 也必须置位,而这正是通行密钥的意义所在。
- 签名:覆盖认证器数据和客户端数据哈希,用注册时保存的公钥验证。
- 计数器。许多同步通行密钥始终报告 0。只有涉及非零值时,WebAuthn 才把计数器减小视为可能的克隆(§6.1.1);切勿因为计数器为 0 而拒绝。
浏览器:两个调用
// Browser side of both ceremonies. JSON options from the server need their base64url
// fields turned into ArrayBuffers; the static helpers on PublicKeyCredential do that.
async function register(optionsJSON) {
const publicKey = PublicKeyCredential.parseCreationOptionsFromJSON(optionsJSON);
const credential = await navigator.credentials.create({ publicKey });
return credential.toJSON(); // send to the server as-is
}
async function signIn(optionsJSON) {
const publicKey = PublicKeyCredential.parseRequestOptionsFromJSON(optionsJSON);
const credential = await navigator.credentials.get({ publicKey });
return credential.toJSON();
}
parseCreationOptionsFromJSON、parseRequestOptionsFromJSON 和 toJSON 属于 WebAuthn Level 3,取代了以往每个项目都要自带的 base64url 转换代码。要让用户名字段在浏览器的自动填充中提供通行密钥,先检查 PublicKeyCredential.isConditionalMediationAvailable(),然后在页面加载时用 mediation: "conditional" 调用 navigator.credentials.get(),并给输入框加上 autocomplete="username webauthn"。
端到端实测
实验用 Playwright 1.64.1(mcr.microsoft.com/playwright:v1.64.1-noble)驱动 Chromium,并通过 Chrome DevTools 协议添加了一个虚拟认证器。http://localhost:3000 上的页面运行 browser.js;驱动脚本把 server.mjs 生成的选项传入页面,再把响应传出来。每一行都来自一次真实的注册或登录:
chromium 156.0.8078.4, virtual platform authenticator with user verification
registration options: challenge 43 chars, pubKeyCredParams -8, -7, -257, attestation none
registered: fmt none, COSE alg -8, transports internal, credentialDeviceType singleDevice, backedUp false, userVerified true, aaguid 01020304-0506-0708-0102-030405060708
second registration on the same authenticator: InvalidStateError: The user attempted to register an authenticator that contains one of the credentials already registered with the relying party.
signed in as alice: userVerified true, newCounter 2, credentialBackedUp false
replayed assertion: unknown or used challenge
response created on http://localhost:4000: Unexpected registration response origin "http://localhost:4000", expected "http://localhost:3000"
synced passkey, attestation "direct": fmt packed, credentialDeviceType multiDevice, backedUp true
sign-in when the authenticator cannot verify the user: NotAllowedError: The operation either timed out or was not allowed. See: https://www.w3.org/TR/webauthn-2/#sctn-privacy-considerations-client.
每一行说明了什么:
- 算法。选项提供了
-8、-7和-257;认证器选择了-8(EdDSA),即其中第一个它支持的算法。顺序表示偏好。 excludeCredentials有效:它触发了 InvalidStateError,你的界面可以据此提示“这台设备上已经有这个账号的通行密钥”。- 无需用户名的登录。
allowCredentials为空;认证器提供了它的可发现凭证,服务器通过凭证 ID 及其保存的公钥找到了对应账号。 - 重放在挑战值这一步失败。挑战值一经使用就删除,正是这一点让第二次尝试失败。
- 浏览器不会拦下所有错误的源。
http://localhost:4000上的页面可以为 RP IDlocalhost创建凭证,因为 RP ID 覆盖该域名的所有端口和子域名。只有服务器的源检查拒绝了它:Unexpected registration response origin。expectedOrigin应保持为一个精确的列表。 - 同步通行密钥会表明自己的身份。开启可备份(BE)后,SimpleWebAuthn 报告了
multiDevice和backedUp true。 - 浏览器会强制执行“要求 UV”。认证器无法验证用户时,浏览器拒绝了请求,报出通用的 NotAllowedError;服务端的
requireUserVerification是第二道防线。
与密码并行推出通行密钥
很少有网站能一次性切换所有账号。WebAuthn Level 3 补上了逐步推出所需的各个部分,它们都可以通过 PublicKeyCredential.getClientCapabilities() 检测到:
- 用户用密码登录后,立即提供通行密钥。具备
conditionalCreate能力时,用mediation: "conditional"调用navigator.credentials.create(),刚刚填入密码的密码管理器就能直接创建通行密钥,无需额外的对话框。 - 从用户名字段登录。
conditionalGet能力就是上文介绍的自动填充流程;有通行密钥的用户直接选择它,其他人照常输入密码。 - 让认证器中的列表与你的列表保持同步。用户在你的账号设置中删除通行密钥后,认证器仍会显示它。
signalUnknownCredential()告诉认证器某个凭证 ID 已不再被接受,signalAllAcceptedCredentials()发送某个已登录用户的完整凭证列表,signalCurrentUserDetails()则在用户重命名账号后更新显示的名称(§5.1.10)。 - 多个域名,一个 RP ID。在
example.com和example.co.uk上都提供登录的品牌,可以只保留一个 RP ID,并在https://example.com/.well-known/webauthn中以{"origins": [...]}的形式列出其他源。当 RP ID 不是页面自身的域名时,浏览器会获取这个文件(相关源请求,§5.11);如果获取失败,结果就是下文所示的SecurityError。 - 为每个凭证记录 BE 和 BS。如果用户唯一的通行密钥只存在于单台设备上且没有备份,丢失那台设备就会失去访问权限;在取消用户的密码之前,提示其再添加一个通行密钥。
证明:什么时候需要
证明回答的是“这个密钥由哪种认证器型号生成”。它不涉及用户本身,也不说明设备的补丁级别或是否已获取 root 权限。使用默认值 attestation: "none" 时,浏览器会在你的服务器看到证明声明之前,把它替换为空声明。实验中服务器仍然收到了认证器的 AAGUID:WebAuthn Level 2 要求浏览器在这种情况下把它替换为 16 个零字节,而 Level 3 去掉了这一步。没有证明时,AAGUID 只是一种声明,不能作为证据。
面向消费者的网站不要要求证明。同步通行密钥的设计初衷是跟随用户到任何设备上,而它们的提供商通常没有为其提供证明:Google 在 Android 上推出通行密钥时表示,它们“最初不会带有任何证明”(fido-dev 邮件列表,2022年)。实验中的虚拟认证器在被请求 "direct" 时返回了 packed 格式的声明,这说明了提供证明的认证器会发送什么,却不能说明某个具体的同步提供商会怎么做,所以要测试你的用户实际使用的提供商。当你必须把注册限制在一组型号内(例如公司发放的安全密钥)时,再请求证明,并对照 FIDO 元数据服务验证证书链。如果想向用户显示其通行密钥提供商的友好名称(“Google Password Manager”“1Password”),社区维护的通行密钥认证器 AAGUID 列表把 AAGUID 映射到名称;它自己的 README 也写明,该列表仅用于显示,不能作为安全决策的依据。
COSE 算法
pubKeyCredParams 列出 COSE 算法标识符(IANA COSE 注册表)。规范自己的示例接受 EdDSA、ES256 和 RS256,并优先选择 EdDSA;这个列表几乎覆盖了目前在用的所有认证器:
| 值 | 名称 | 说明 |
|---|---|---|
| -8 | EdDSA | 在 WebAuthn 中仅指 Ed25519(§5.8.5);RFC 9864 已在 COSE 注册表中将其标为弃用,改用 -19 Ed25519,但认证器仍然输出 -8 |
| -7 | ES256 | ECDSA P-256;支持最广泛。在 COSE 中已弃用,改用 -9 ESP256,情况相同 |
| -257 | RS256 | RSA PKCS#1 v1.5;Windows 10 和部分 Windows 11 安装中的 Windows Hello 只创建 RS256 凭证(Yubico 在 W3C 邮件列表上的说明) |
| -48, -49, -50 | ML-DSA-44, -65, -87 | 由 RFC 9964 注册(2026年5月)。Chromium 156 的虚拟认证器接受了 -49(见下文);在依赖它们之前,先检查真实的认证器和你的服务器端库 |
即使你更倾向于 -8,也要在列表中保留 -7 和 -257:pubKeyCredParams 为空时,规范自己的默认值就是 ES256 和 RS256。把算法与凭证一起保存;SimpleWebAuthn 把它保存在所存储的 COSE 公钥中。
DOMException 错误
navigator.credentials.create() 和 get() 通过一小组异常报告失败,这些异常列在 WebAuthn Level 3 的 §5.1.3.1 和 §5.1.4.3 中。下面是 Chromium 156 针对常见错误抛出的异常,测试对象是一个不能存储可发现凭证的虚拟 USB 安全密钥:
only an unknown algorithm (-65000) NotAllowedError: The operation either timed out or was not allowed. See: https://www.w3.org/TR/webauthn-2/#sctn-privacy-considerations-client.
only ML-DSA-65 (-49) created, COSE alg -49
residentKey required, key cannot store it NotAllowedError: The operation either timed out or was not allowed. See: https://www.w3.org/TR/webauthn-2/#sctn-privacy-considerations-client.
rp.id "example.com" on localhost SecurityError: The relying party ID is not a registrable domain suffix of, nor equal to the current domain. Subsequently, an attempt to fetch the .well-known/webauthn resource of the claimed RP ID failed.
user.id of 65 bytes TypeError: User handle exceeds 64 bytes.
aborted with AbortController AbortError: signal is aborted without reason
| 异常 | 规范中的含义 | 怎么处理 |
|---|---|---|
NotAllowedError | 兜底错误:用户取消、超时、没有匹配的凭证、没有任何认证器满足要求 | 视为“未完成”;提供重试和其他登录方式。切勿显示原始消息 |
InvalidStateError | 认证器识别出了 excludeCredentials 中的某一项 | 告诉用户这台设备上已经有本站的通行密钥 |
SecurityError | RP ID 既不是页面的域名,也不是它的可注册后缀,或者相关源校验失败 | 修正配置;参见 SecurityError 页面 |
NotSupportedError | pubKeyCredParams 中没有可用的算法 | 提供 -7 和 -257 |
ConstraintError | 要求了 residentKey 或 userVerification,但没有能满足要求的认证器 | 实践中与 NotAllowedError 同样处理 |
AbortError | 你的 AbortController 取消了调用 | 从自动填充切换到模态提示时属于预期情况 |
TypeError | 选项格式错误,例如用户句柄超过 64 字节 | 你构造的选项有 bug |
实验中有两个结果与规范的列表不同:唯一的算法未知时,Chromium 报出的是 NotAllowedError,而不是 NotSupportedError;要求一个无法存储可发现凭证的安全密钥提供可发现凭证时,报出的也是 NotAllowedError,而不是 ConstraintError。靠 NotSupportedError 或 ConstraintError 分支向用户解释问题的代码,在 Chromium 中遇到这些情况时永远收不到这两种异常。这两个 API 都只存在于安全上下文中。在不是 localhost 的普通 HTTP 页面上,Chromium 156 报告 isSecureContext: false,navigator.credentials、PublicKeyCredential 和 crypto.subtle 全部为 undefined,因此代码在任何流程开始之前就会抛出 TypeError(Web Crypto 的对应情况见 crypto.subtle is undefined)。所有使用通行密钥的页面都要通过 HTTPS 提供;http://localhost 是唯一的例外,仅用于开发。
检查清单
- RP ID 设为用户看到的可注册域名;
expectedOrigin是一个明确的 HTTPS 源列表,包含每一个提供登录的子域名。 residentKey: "required"、userVerification: "required",服务器对每个响应都检查 UV。pubKeyCredParams包含-8、-7、-257;excludeCredentials填入该用户已有的凭证。- 挑战值随机、一次性、有效期短,并保存在服务端。
- 保存凭证时包含 ID、公钥、计数器、传输方式、BE 和 BS 标志、AAGUID 以及用户可编辑的名称;用户可以注册多个凭证,也可以删除它们。
- 除非你要限制认证器型号,否则证明设为
"none";需要限制时,对照 FIDO MDS 验证。 - 账号恢复不能比通行密钥本身更弱:使用第二个通行密钥或经过验证的恢复途径,而不是一条能绕过一切的短信验证码。
来源
- Web 身份验证规范 Level 3,W3C 推荐标准,2026年8月25日:§5.1.3.1、§5.1.4.3、§5.8.5、§6.1、§6.1.1
- IANA COSE 算法注册表;RFC 9864;RFC 9964
- SimpleWebAuthn 文档
- FIDO 元数据服务
- 通行密钥认证器 AAGUID 列表
- Chrome DevTools 协议:WebAuthn 域