Overview
Passkey 到底是什么?

Passkey 到底是什么?

June 9, 2026
6 min read

引子:登录这件事,从未让人满意

前两天我打开自己的密码管理器,里面躺着 200 多条记录:3 套主密码、6 个 2FA 备份码、2 把 Yubikey、还有一堆用一次就再没登录过的小破站。这堆东西已经成了我数字生活的“地下室“,每翻一次都觉得哪里不对劲——再多一样就要彻底失守,少一样又浑身不踏实。

更不对劲的是这套机制的脆弱:

  • 弱密码:人脑记不住长随机串,于是 123456、passwordqwerty 永远是排行榜冠军。
  • 复用:同一个密码用在十几个站——总不能为每个小破站都开一条 32 位随机串。
  • 撞库:你在 A 网站的密码泄露了,攻击者拿着去试 B、C、D;HaveIBeenPwned 那种查询站的存在本身就说明这事每天都在发生。
  • 钓鱼:再复杂的密码,发到假网站上就是给人家送钥匙。再警惕的用户,也会在某个加班到凌晨的深夜手抖一次。

于是大家补 2FA。可 2FA 也不是银弹——TOTP(基于时间的一次性密码,那种 6 位数字)依赖用户肉眼核对域名,遇到仿冒域名直接破防;SMS 验证码更别提,SIM swap 攻击下手机号都能被人过户走,新闻里隔三差五就有名人账户被这样端掉。

绕了一大圈,问题其实很朴素:有没有一种登录方式,既安全到扛得住钓鱼,又省事到不需要我记任何东西、不用抄备份码、不用揣着硬件钥匙出门?

passkey 想回答的就是这个问题——它不是一个新概念,背后是一整套叫 FIDO2 的标准协议栈。

名字的迷宫:Passkey / WebAuthn / FIDO2

passkey 是什么?和 WebAuthn 什么关系?FIDO2 又是什么?第一次接触这套东西的人,大概率被这三个名字绕晕。它们之间的关系,其实可以画成一张图:

FIDO 联盟(行业标准组织)
FIDO2 标准
├── WebAuthn(W3C 规范,浏览器侧 API)
│ └── navigator.credentials.{create,get}()
└── CTAP2(协议,浏览器↔认证器)
└── USB / NFC / BLE 安全密钥 / 平台认证器

FIDO 联盟是背后的行业标准组织(成员里有 Microsoft、Apple、Google 这些大厂),负责制定规范。FIDO2 是他们这一代认证标准的统称,由两部分拼成:

  • WebAuthn:W3C 制定的浏览器侧 API 规范。JavaScript 调用的就是 navigator.credentials.create()navigator.credentials.get() 这两个入口。它定义了浏览器怎么跟认证器说话、返回什么数据结构。
  • CTAP2:客户端到认证器(Client To Authenticator Protocol)的协议,规范浏览器/操作系统和实体认证器(比如 Yubikey)之间怎么通信。USB、NFC、BLE 都是它的传输通道。

passkey 是什么? 它是 FIDO2 标准下的一类凭据——更具体地说,是“可同步、可发现的凭据“(synced discoverable credential)。所谓“可发现“,意思是认证器本地能列出这个域名下的所有凭据,登录时不用让用户先输入用户名;所谓“可同步“,意思是它可以被同步到云端、跨设备使用。

所以 passkey 不是一个新协议,而是一种用法:它跑在 FIDO2(WebAuthn + CTAP2)这套标准之上,是 FIDO2 凭据里用户体验最好的一类。FIDO Alliance 的官方页面把它定义成“a FIDO authentication credential“,强调它的无密码、防钓鱼特性。

一个常见误区:把 passkey 等同于 WebAuthn。其实 WebAuthn 是一个API 规范,passkey 是 FIDO2 体系下一类凭据;passkey 的注册和登录流程要调 WebAuthn API,但 WebAuthn 本身也支持设备绑定的、非可发现的传统凭据。

密码学骨架:公私钥如何“干掉“密码

上一节我们分清了 passkey / WebAuthn / FIDO2 三个名字,但 passkey 到底靠什么“干掉“密码?答案藏在一对很朴素的密码学概念里——公钥私钥

整个登录过程,浏览器、认证器、服务器三方只交换公钥和签名,私钥从头到尾不出设备。服务器永远见不到私钥,所以服务器被拖库,攻击者拿到的也只有一堆公钥——没有私钥,签不出 signature,账号照样安全。

为了把这个机制讲透,我们先把三方角色对一下号:浏览器负责按规范组装数据、转发请求;认证器(authenticator,可以是设备内置的 Touch ID / Windows Hello,也可以是外置的 YubiKey)才是真正持有私钥、做出签名的那个实体;服务器在 WebAuthn 里有个更正式的名字——依赖方(Relying Party,简称 RP),也就是用户要登录的那个网站。后面流程图里出现的“RP“就是这个服务器。

注册和登录分别对应两次 API 调用,流程如下:

注册(register):用户告诉服务器“我要开个 passkey 账号“。

浏览器 服务器 (RP)
│ │
├── POST /register 携带 username ────▶│
│ │ 生成 challenge + user.id
│◀── { challenge, user, rp } ──────────┤
│ │
│ 调用 navigator.credentials.create() │
│ → 私钥留在 authenticator │
│ → 公钥 + attestation 送出 │
├── POST /register 携带 publicKey ───▶│
│ │ 存 (userId, publicKey, credentialId)

登录(login):用户想进站,服务器让他“签个字“证明自己是私钥持有人。

浏览器 服务器 (RP)
│ │
├── POST /login 携带 username ───────▶│
│ │ 取出 publicKey;生成 challenge
│◀── { challenge, rpId, allowCredentials } ─┤
│ │
│ navigator.credentials.get() │
│ → 私钥签名 challenge │
│ → 返回 assertion │
├── POST /login 携带 signature ──────▶│
│ │ 用 publicKey 验签
│◀── session cookie / token ──────────┤

这个机制带来三条真正改变游戏规则的性质:

  • 服务器只存公钥,公钥泄露 = 没泄露。传统密码的“共享秘密“模型下,服务器拖库就等于用户密码全丢;passkey 模型下,泄露的公钥无法用来伪造签名(ECDSA / RSA 的安全性都基于“知道公钥也反推不出私钥“)。哪怕 Vaultwarden 哪天被打成筛子,拿到的也只是一堆无用的公钥。
  • 抗钓鱼,根子在 RP ID。WebAuthn 在做签名时,会把当前页面的 origin(也就是 RP ID)哈希进签名数据。钓鱼站可以骗你输入密码,但它拿不到真实站点的 origin,认证器签出来的 assertion 在真实站验签时一定不通过。W3C WebAuthn 规范(Level 2 Recommendation)把这一点写死在了协议层——这才是 passkey“抗钓鱼“三个字的真正重量。
  • 每次登录的 challenge 都是一次性的,天然抗重放。服务器每次下发一个全新的随机数让认证器签名,签完即作废。攻击者就算截到上一次登录的完整 assertion 包,再发回服务器也验不过——challenge 对不上。这意味着 passkey 不用像传统 session 那样小心翼翼地防重放、防中间人保存凭据,协议本身就兜住了。

一次真实的登录:WebAuthn API 拆解

流程图看着抽象,落到代码上其实就两个浏览器 API。下面用最小可运行的伪代码拆一遍,所有字段名都对齐 W3C WebAuthn 规范。

注册:调 navigator.credentials.create(),告诉认证器“帮我造一对密钥“。

const credential = await navigator.credentials.create({
publicKey: {
challenge: crypto.getRandomValues(new Uint8Array(32)),
rp: { name: "Example Corp", id: "example.com" },
user: {
id: Uint8Array.from("user-123", (c) => c.charCodeAt(0)),
name: "[email protected]",
displayName: "Alice",
},
pubKeyCredParams: [
{ type: "public-key", alg: -7 }, // ES256
{ type: "public-key", alg: -257 }, // RS256
],
authenticatorSelection: {
residentKey: "required", // discoverable credential
userVerification: "preferred",
},
attestation: "none",
timeout: 60000,
},
});
// 把 credential.id 和 credential.response.getPublicKey() 发给服务器

几个字段值得多说一句:

  • challenge 必须是 BufferSource(这里用 Uint8Array),不是 base64 字符串——服务器下发的是二进制,浏览器直接用就行。
  • pubKeyCredParams 是算法白名单,-7 是 ES256(ECDSA P-256 + SHA-256),-257 是 RS256(RSASSA-PKCS1-v1_5 + SHA-256),认证器会按顺序挑一个它支持的。
  • authenticatorSelection.residentKey: "required" 表示生成可发现凭据(discoverable credential,旧称 resident key)——认证器自己记着这个凭据,登录时不用先告诉它用哪把。
  • attestation: "none" 表示不要“出身证明“,对绝大多数网站够用。

登录:调 navigator.credentials.get(),让认证器用私钥签个名。

const assertion = await navigator.credentials.get({
publicKey: {
challenge: crypto.getRandomValues(new Uint8Array(32)),
rpId: "example.com",
userVerification: "preferred",
timeout: 60000,
},
});
// 把 assertion.response.signature + authenticatorData + clientDataJSON 发给服务器

服务器拿到这三样东西,用事先存好的公钥验签,签过了就发 session。这一步完全在服务器侧完成——浏览器只负责把数据原封不动转交过去,既看不到私钥,也没法伪造签名(即使浏览器被攻陷)。

最后捋一下三个常被混用的术语:

  • attestation(证明):注册时认证器出示的“我是谁、我从哪来“的签名声明,里头包含 attestationObject(注意这是 CBOR 编码的二进制,不是 JSON)。一般设 "none" 即可,除非你有合规要求要追溯认证器型号。
  • assertion(断言):登录时认证器对 challenge 的签名返回,注册时没有这个。navigator.credentials.get() 的返回值里,response.signature 就是断言的核心,配套的 response.authenticatorDataresponse.clientDataJSON 一起送回服务器才能验签。
  • userVerification(用户验证):是否要求“用户在本机做了一次验证“——指纹、面容、PIN 都算;不要求的话("discouraged")用户摸一下认证器就算通过。设成 "required" 是更严格的姿态,金融类场景一般会这么配。

顺带提两个 WebAuthn 的硬性环境约束:API 只在 secure context(HTTPS) 下可用,且只在 top-level context 中生效——塞在 iframe 里调会直接抛错。MDN 的 PublicKeyCredential 页面把这两条写在最前面,部署前务必对一遍。

同步模型:Passkey 怎么“跟着你走“

讲到这里,passkey 看起来已经够香了——公私钥签名、服务器只存公钥、原生抗钓鱼。但如果你用过 YubiKey 这类传统 FIDO2 安全密钥,一定会有个疑问:这些年来它不也是这样工作的吗?passkey 比 YubiKey 到底强在哪?

答案藏在一个关键设计差异里:同步

FIDO Alliance 的官方 FAQ 把通行密钥明确分成两类:

  • 可同步通行密钥(synced passkey):通过云服务在用户多台设备之间同步的 passkey。
  • 设备绑定通行密钥(device-bound passkey):私钥永不离开单一设备的 passkey,传统 FIDO2 安全密钥(包括 YubiKey)就属于这一类。

YubiKey 是典型的漫游认证器(roaming authenticator,外置的硬件密钥)——它把私钥焊死在硬件里,丢了就等于失联,所有设备都得重新注册一遍。passkey 走的则是另一条路:私钥可以被加密后同步到云端,换台设备登录时直接用。

同步方案对比

方案 同步方式 抗丢失 跨设备登录
YubiKey(设备绑定) 不同步,硬件随身 弱:丢了 = 失联 QR + BLE 模式(caBLE)
Apple iCloud Keychain 端到端加密同步到 Apple 设备 中:iCloud 恢复 同 Apple ID 自动
Google Password Manager 端到端加密同步到 Android/Chrome 中:账号恢复 同 Google 账号自动
1Password / Bitwarden / KeePassXC 端到端加密同步到自建 vault 强:recovery code / 备份 vault 跨平台扫码 + vault 同步

最后一类是 FIDO Alliance 定义的“第三方 passkey provider“(third party provider)——passkey 存在第三方应用或浏览器扩展里、随 vault 跨平台同步。1Password、Bitwarden、KeePassXC 都属于这种模式。

“端到端加密“到底是什么意思?

很多人看到“同步“就慌:私钥传到云上,不是裸奔吗?关键就在端到端加密(E2EE)这个机制上:

  • 私钥在本地设备上用一把设备本地密钥加密,密文才上传到服务商
  • 服务商只看到密文,没有解密能力——既看不到私钥明文,也没法用它签东西
  • 恢复机制是私钥加密链的“根“:Apple 用 iCloud 高级数据保护(Advanced Data Protection)的设备端 PIN、Google 用屏幕锁 PIN、Bitwarden / 1Password 用 master password——任一环节被攻破才会危及私钥

换句话说,服务商被拖库 ≠ 私钥泄露。这一点和传统密码管理器的“主密码 + vault 加密“模型是同一套思路,只是把“密码条目“换成了“私钥“。

跨设备登录的两种模式

理解了同步之后,再回头看“换台设备怎么登录“这件事,就会发现其实有两条完全不同的路:

  • 传统 caBLE / QR + BLE 模式(私钥不出设备):本机蓝牙通道把新设备和旧设备配对,挑战值通过 QR 码传到旧设备,私钥在旧设备上签名后再回传。代表场景:YubiKey 在手机 App 上登录、新 Mac 在旧 iPhone 上登录。FIDO 联盟称之为 Client to Authenticator Bluetooth Low Energy(caBLE)。
  • 云同步模式(私钥已同步到目标设备):因为密文已经在新设备上解密出来了,登录就是一次普通的本地签名——没有 QR 码,没有蓝牙配对。代表场景:在新 iPhone 上用 iCloud Keychain 登录。

两者的安全边界其实一致——私钥明文永远不出设备——只是 caBLE 模式下“持有私钥的那台设备“是固定的(YubiKey),而云同步模式下“持有私钥的那台设备“会随你换机而变化。这也是为什么 passkey 把“丢了 = 失联“的痛点彻底解决掉:同步本身就是容灾。

实践:自托管 Vaultwarden 用上 Passkey

在动手之前,先把范围钉死:本节讲的“用上 passkey“,是把 passkey 当作 Vaultwarden 账户登录时的二步验证因子——流程是“先输主密码,再过 passkey 这一关“。它不是用 passkey 替换主密码:Vaultwarden 与 Bitwarden 官方客户端目前都不支持完全无密码登录(passkey-only),主密码这一环节依然不可省。理解了这个边界,我们再展开。

Vaultwarden 是什么

Bitwarden 官方服务端是用 C# 写的,资源占用偏大,核心组件并不完全开源——这对部分自托管爱好者在许可证兼容性上是个硬伤。于是社区用 Rust 重写了一个兼容 Bitwarden Client API 的替代服务端实现,叫 Vaultwarden。它和 Bitwarden 官方客户端(桌面、浏览器扩展、移动端)完全兼容,但代码量小、内存占用低,非常适合跑在 NAS、小 VPS、家庭服务器这类资源有限的机器上。Bitwarden 官方客户端的“两步登录 → FIDO2 WebAuthn“功能,Vaultwarden 同样支持——这是它 README 中明确列出的能力。

关键前提:HTTPS + DOMAIN

FIDO2 WebAuthn 2FA 在 Vaultwarden 中始终开启——它没有“是否启用 WebAuthn“的开关,也没有类似 WEBAUTHN_ENABLEDFIDO2_ENABLEDPASSKEY_ENABLED 这种环境变量。客户端发起,Vaultwarden 作为后端在认证链路上处理。但是,启用前必须满足两个硬性条件

The web-vault requires the use of HTTPS and a secure context for the Web Crypto API. That means it will only work if you enable HTTPS. We also suggest to use a reverse proxy.

The domain must match the address from where you access the server. It’s recommended to configure this value, otherwise certain functionality might not work, like attachment downloads, email links and U2F.

—— Vaultwarden 官方 README 与 .env.template

注意 README 中的 “U2F” 是沿用旧 FIDO 术语的说法;现代 Bitwarden 客户端的 FIDO2 WebAuthn 2FA 走的是同一条依赖通道。WebAuthn API 本身也只在 secure context(即 HTTPS)下可用,且只在 top-level context 中生效——塞在 iframe 里调会直接抛错,这是 W3C 规范和 MDN 文档都写在最前面的硬约束。

启用步骤骨架

第一步:部署时设好 DOMAIN 启动 Vaultwarden 时,确保环境变量里 DOMAIN 指向你实际访问 vault 的 URL,例如:

DOMAIN=https://your-vault.example.com

your-vault.example.com 只是占位——你用什么域名,就把它写全。常见的做法是把 Vaultwarden 放在 Nginx / Caddy 之类反向代理后面,由代理负责 HTTPS 终结和证书续期(Let’s Encrypt 即可)。

第二步:在客户端开启 FIDO2 WebAuthn 2FA。 Vaultwarden 自身不需要额外开关。用任意一个 Bitwarden 官方客户端(桌面、浏览器扩展、移动端任选)登录你的 vault,进入 “设置 → 两步登录 → FIDO2 WebAuthn” 入口,按提示用本机的 passkey 注册一次——iCloud Keychain、Windows Hello、Bitwarden 自身 vault 里的 passkey、外置的 Yubikey 都行。Bitwarden 客户端版本要求以官方文档为准bitwarden.com/help/setup-two-step-login-fido),本节不写具体版本号,避免臆造。

第三步:下次登录验证。 退出后重新登录,流程变成“先输主密码 → 弹出 passkey 提示 → Touch ID / Windows Hello / 指纹 / PIN 验证“。主密码仍是第一道关,passkey 是叠加在它上面的二步因子。

使用中的几个发现(脱敏抽象)

  • 跨设备同步。在一台 Bitwarden 客户端注册的 passkey,会被加密同步到该 vault 的其他客户端。换台设备用同一 vault 登录后,这把 passkey 直接可用,不必逐台重新注册。这也是上一节“可同步通行密钥“模型在密码管理器场景下的具体落地。

  • HTTPS 强制对 WebAuthn API 的影响。如果某次调试时把 Vaultwarden 临时跑在 HTTP 上、忘了挂代理,浏览器会直接拒绝弹出 passkey 提示——WebAuthn API 在非 secure context 下不可用,连注册那一步都进不去。这条几乎不写在文档显眼位置,但踩过一次就再也忘不了。

  • 跨子域部署时一个相关 flag。如果你把 vault 放在 vault.example.com、但用 login.example.com 之类不同子域提供入口,会涉及 WebAuthn related-origins 的处理。Bitwarden 客户端的实验性特性里有一个 flag:pm-30529-webauthn-related-origins这是 Bitwarden 客户端层面的特性,不是 Vaultwarden 自己的环境变量——具体是否启用、以官方文档为准。

  • 抽象化的注册调用。Bitwarden 客户端最终落到 WebAuthn API 上时,注册调用大致长这样(用 vault.example.com 作占位):

    // 注册:在 vault.example.com 下生成 passkey
    const credential = await navigator.credentials.create({
    publicKey: {
    challenge: crypto.getRandomValues(new Uint8Array(32)),
    rp: { name: "Vault", id: "vault.example.com" },
    user: {
    /* ... */
    },
    pubKeyCredParams: [
    { type: "public-key", alg: -7 }, // ES256
    { type: "public-key", alg: -257 }, // RS256
    ],
    authenticatorSelection: {
    residentKey: "required",
    userVerification: "preferred",
    },
    },
    });

    这里 rp.id 必须等于或高于当前 origin 的有效域名——rp.id = "example.com" 可以覆盖 vault.example.com,反过来不行。这条规则是 W3C WebAuthn 规范写死的,不只是“约定“。

边界声明

本节提到的部署、错误、坑都是公开文档或规范层面的公共知识,不含任何个人实例信息——没有真实域名、IP、用户名、token、部署平台。your-vault.example.comvault.example.com 仅是 RFC 2606 保留的占位域名,按惯例用来举例。截图、UI 细节、客户端版本号一律不写,需要时以 Bitwarden 与 Vaultwarden 官方文档为准。

现实世界:现在能用吗

讲到这里,原理部分已经够厚了。回到一开始那个朴素的问题——现在能用吗? 答案分两层:底层 API 已经成熟到不能再成熟,但实际落地速度仍在慢慢爬坡。

先看底座:WebAuthn / PublicKeyCredential 这套 API 在 MDN 兼容性表上被标为 “Baseline Widely available”——也就是说,所有主流桌面浏览器均已支持,门槛点写明“自 2021 年 9 月起“全平台统一可用。基线之外,唯一剩下的硬性约束是安全上下文(secure context):API 只在 HTTPS / localhost 下可用,且只能在 top-level context 生效——塞在 iframe 里调不到。这是规范层写死的,跨平台、跨浏览器一致。落到具体平台上的注册、同步、安全上下文要求,可以粗略画成这样一张表:

平台 注册 passkey 跨设备同步 安全上下文要求
Chrome / Edge via Google Password Manager HTTPS / localhost
Firefox 有限 HTTPS / localhost
Safari via iCloud Keychain HTTPS
iOS / Android 系统级 HTTPS

注意这张表只画行为差异,不写小版本号——MDN 的“Baseline“标签已经把“哪些版本支持“这件难维护的活儿直接收编了,单独标版本号既没必要、也容易过期。

在大型公共站点这一侧,公开可查的、已经接入 passkey 登录的厂商名单里,GitHubGoogleCloudflarePayPal 这几家是经常被工程博客和发布说明点名的代表——它们的具体策略各自不同(有的做主登录、有的做 2FA、有的做消费端),但都走 WebAuthn 这套标准协议,所以认证侧的用户体感是一致的。这一节不展开各自的产品细节,只承认一件事:标准之上已经能跑生产。

回到自托管视角:自托管 Vaultwarden 默认就支持 passkey 2FA——这件事呼应上一节的“无开关“结论。Vaultwarden 的 README 明确把 FIDO2 WebAuthn 列为支持的两步登录方式之一,.env.template 里也没有 WEBAUTHN_ENABLED 之类的开关变量。也就是说,只要上一节把 HTTPS + DOMAIN 配好,passkey 2FA 这一步不需要任何额外配置就能用——这是很多自托管爱好者没注意到的“白送“的能力。

还没解决的边角

passkey 不是银弹。规范写得很漂亮,浏览器支持也到位,但落到用户和工程团队手里,依然有几块边角料没被磨平。

第一,账号恢复。 passkey 抗钓鱼、抗拖库,但代价是它的“找回“路径非常窄:如果一个用户丢了所有设备、又没有记 recovery code、又没在多端同步——账号就基本失联了。passkey provider(iCloud Keychain、Google Password Manager、各家第三方 vault)各自有恢复机制,但都不是 100% 鲁棒。spec 层面这件事尚无统一解,SSO / SCIM 这些企业级恢复路径也不在 passkey 标准覆盖范围内,部署前必须把“用户最坏情况下怎么找回账号“想清楚。

第二,跨域 UX。 WebAuthn 在设计层面就把每个 RP ID 当成独立的凭据空间——这本来是抗钓鱼的代价,但也意味着 example.com 注册的 passkey 在 example.org 用不了,跨子域、跨主域都得分别注册。对用户来说就是“换站就要重新走一次 passkey 流程“,体验上不如“同一个 Apple ID 全网通行“的愿景那么丝滑。spec 提供了 related-origins 等扩展来缓解,但生态对齐还在路上。

第三,服务端实现成本。 WebAuthn RP(依赖方)这一侧并不“开箱即用“:注册返回的 attestationObjectCBOR 编码的二进制,要解析、要验签、要查 attestation chain(如果开了 attestation != "none");登录返回的 assertion 也要把 authenticatorDataclientDataJSONsignature 拼起来再用公钥验签。自己从零撸很容易踩坑——好在社区已有成熟库:Rustwebauthn-rsPythonpy_webauthnNode / TypeScriptSimpleWebAuthn 都是生产可用的选项,生产部署时强烈建议直接接成熟库,别自己重新发明轮子。

结语:为什么这次可能真的不一样

一句话回顾:passkey 用一套标准的公私钥机制,把密码最让人头疼的痛点——拖库失守、钓鱼泄露、记不住长串——一次性绕了过去,同时还能塞进 iCloud Keychain、1Password、Bitwarden 这些用户已经在用的密码管理器生态里。

一句话前瞻:标准化(WebAuthn / FIDO2)+ 同步生态(系统级 + 第三方 vault)同时成熟,这套组合前几代身份协议都没凑齐过——这次看起来,可能真的不一样。