密码正在退场:Passkey、WebAuthn,和那些没人告诉我的坑

上个月我收到一封邮件,开头是那句熟悉的话:「我们检测到您的账户密码可能已泄露,请立即修改。」

我很配合地点进去改了一个,然后顺手打开那个存了几百条记录的密码管理器,看着里面一半以上的条目都带着「建议更换」的黄色小三角。那一刻我意识到一个有点荒谬的事实:我是一个写了十年登录页的人,而我自己的数字生活,仍然建立在「把同一串字符交给几十个网站各自存一份」这个模型上。

一张散落着写满密码又被划掉的纸条的暗色桌面,上方浮着一部显示生物识别登录提示的手机

真正让我动手的,是 FIDO Alliance 在今年 5 月 World Passkey Day 发布的《State of Passkeys 2026》:他们估计全球已经有 50 亿个 passkey 在使用;90% 的人听说过 passkey;75% 的人至少在一个账户上启用了它;49% 的人在支持的地方会经常用。企业侧也有 68% 已经部署或正在部署。

这些数字里最扎眼的其实不是 adoption,而是另一条:过去一年里,三分之一的人(33%)经历过账户被盗或收到过泄露通知。密码这套东西,到今天还在实实在在地伤人。

# 密码的问题,从来不是「不够复杂」

我们对付密码的手段一直是补丁式的:要求大写小写数字符号、定期更换、再加一条短信验证码。但这些补丁都没碰到底层那个结构性问题——

密码是一个共享秘密。

你要登录,就得把秘密发给服务器;服务器必须保存一份可以用来验证它的东西(哪怕是哈希)。这意味着:

  • 服务器被拖库 → 秘密的残留物就流出去了,可以离线爆破、可以撞库;
  • 你在一个长得一模一样的页面上输入了它 → 秘密直接交给了攻击者,验证码也一样能转发;
  • 中间人、键盘记录器、浏览器插件 → 拿到的是秘密本身,用不用得上只取决于它有没有过期。

暗色机房里一排屏幕,铺满泄露的邮箱与密码记录,红色告警灯

Passkey 换掉的是这一整条链路的核心:服务器不再持有秘密,只持有公钥。

这一个改动同时干掉了上面三类攻击。数据库里全是公钥,拖走也没用;钓鱼站拿不到能用的东西,因为签名是浏览器按「当前访问的域名」去要的,假域名要不到真站点的签名;中间人截获的那串字节是一次性签名,重放无效。

# Passkey 到底是个什么东西

先拆掉一个常见误解:passkey 不是「指纹登录」的另一种说法。生物识别只是解锁本地私钥的方式,跟协议无关——你用 PIN、用图案、用指纹,对服务器来说都一样,它只看到一个签名。

技术上,一个 passkey 是一对非对称密钥:

暗色桌面微距:一枚发光的黄铜公钥与一枚被封在安全芯片里的哑光私钥

  • 私钥:生成后存在设备的 Secure Enclave / TPM / Android Keystone 里,或者存在密码管理器(1Password、Bitwarden、iCloud 钥匙串)里。它永远不出那个安全边界;
  • 公钥:注册时交给服务器,跟 user id 一起存下来,就是数据库里那一行。

WebAuthn 里有个专门的叫法:discoverable credential(以前叫 resident key)。意思是凭据本身自带用户信息,登录时不需要先输入用户名——服务器发一个空的用户名挑战,浏览器自己列出「这个域名下你有哪些 passkey」让你选。这正是 passkey 和普通 WebAuthn 第二因素的区别:后者你得先输密码,再摸一下钥匙;前者它自己就是完整的认证

底层是 FIDO2 = WebAuthn(浏览器 API,W3C)+ CTAP(浏览器和 authenticator 之间的协议)。你在浏览器里只碰得到前者。

# 一次注册、一次登录,实际发生了什么

我把接入的代码简化了一遍。先看注册,服务端先给一组选项:

// Node / SimpleWebAuthn
const options = await generateRegistrationOptions({
  rpName: "Bougie's Blog",
  rpID: "bougieL.github.io",
  userID: Buffer.from(user.id), // 稳定、不可变
  userName: user.email,
  userDisplayName: user.name,
  attestationType: "none",
  excludeCredentials: existing.map((c) => ({ id: c.credentialID, transports: c.transports })),
  authenticatorSelection: {
    residentKey: "required", // 要的就是 discoverable
    userVerification: "preferred",
  },
})
// 把 challenge 存进 session,一次性
session.challenge = options.challenge

浏览器侧只有一句话:

const credential = await navigator.credentials.create({ publicKey: options })
await fetch("/api/passkey/register", {
  method: "POST",
  body: JSON.stringify(credential),
})

注意 options 里的 challenge 必须是 Uint8Array,而服务端生成的通常是 base64url 字符串,客户端要做一次转换(SimpleWebAuthn 的 startRegistration 帮你做了,手写的话别漏)。

服务端拿到 credential 之后做验证:

const verification = await verifyRegistrationResponse({
  response: credential,
  expectedChallenge: session.challenge,
  expectedOrigin: "https://bougieL.github.io",
  expectedRPID: "bougieL.github.io",
})
// 然后把 credentialID + publicKey + counter 存进数据库

登录是镜像的流程,navigator.credentials.get() 拿一个 assertion,服务端验签。这里我加了一个我觉得体验提升最大的东西——Conditional UI,让 passkey 直接出现在用户名输入框的自动填充里,不需要用户先点「使用 passkey 登录」:

<input type="text" name="username" autocomplete="username webauthn" />
const credential = await navigator.credentials.get({
  publicKey: { challenge, rpId, userVerification: "preferred" },
  mediation: "conditional", // 关键
})

抽象示意图:左侧笔记本与右侧安全密钥之间,流动着发光的挑战-响应光带

# 服务端到底验了哪几件事

这部分是我接的时候最想有人给我讲清楚的。一次登录,服务端手里的输入只有三段字节:clientDataJSONauthenticatorDatasignature。校验大致七步:

  1. clientDataJSON.type 必须是 webauthn.get(注册时是 webauthn.create)——防止拿注册响应冒充登录;
  2. clientDataJSON.challenge 等于 session 里那个,且用过即删
  3. clientDataJSON.origin 等于预期的完整 origin,不能只比域名;
  4. authenticatorData 前 32 字节是 rpIdHash,必须等于 SHA256(rpID)——这一条就是抗钓鱼的全部依据
  5. flags 字节里 UP(用户在场)必须置位,如果请求时写了 userVerification: "required",UV 位也要置位;
  6. 用数据库里存的 COSE 公钥验签:authenticatorData || SHA256(clientDataJSON)
  7. signCount 必须比上次大。

第 7 条有个坑:同步型 passkey 的 signCount 常常永远是 0。它设计出来是为了检测克隆的硬件钥匙,但同步凭据本来就在多台设备上合法存在,计数器没有意义。所以别把它写成硬性失败,记录 + 告警就够了。

flags 里还有两位值得单独拎出来:BE(backup eligible,可备份)BS(backup state,已被备份)。它们告诉你这个凭据是不是同步型的——后面会讲到,这两位现在比 signCount 有用得多。

# 同步,是它最大的优点,也是最大的争议

最初的 FIDO 模型里,私钥是硬件绑定的:它在那枚钥匙里,拿不走。这也意味着丢掉钥匙 = 丢掉账户。

后来苹果和谷歌做了一件改变格局的事:让 passkey 通过 iCloud 钥匙串 / Google 密码管理器在设备之间同步。于是你现在在 iPhone 上注册的 passkey,可以在 Mac 上直接用;换手机也不至于当场锁死自己。

深色空间中,笔记本、手机、平板由柔和的同步光线连成一个三角形

代价是威胁模型变了:

  • 私钥现在存在云端,且(各家实现不同)由 Apple / Google 的账户安全保护。你的 Apple ID / Google 账号成了新的单点——它被攻破,攻击者能拿到的是你所有的 passkey,而不只是一个网站的密码;
  • 「不可导出」这个硬件保证没有了,企业合规场景里那些「凭据必须绑定受管设备」的要求就没法靠默认 passkey 满足。

所以 BE/BS 那两位变得重要:企业可以在注册时要求 device-bound(BE=0),或者用 attestation + AAGUID 白名单来限定允许的 authenticator 型号。个人站点则完全相反——同步是好事,别去拦。

我的判断是:对绝大多数消费级场景,同步 passkey 仍然远比密码安全。攻击者要偷你的 iCloud 钥匙串,得先突破一个开启了 2FA 的 Apple ID;而偷密码只需要你点错一次链接。真正的风险排序里,钓鱼的量级比账号接管高太多了。

# 2026 年,锁定的问题终于在被解决

我去年不敢把 passkey 推荐给家人的一个原因是:它太容易把人锁在门外。同一生态内很丝滑,跨平台就很尴尬,而各家又不让你把凭据导出。

今年这块松动了:

  • 2026 年 8 月 25 日,WebAuthn Level 3 正式成为 W3C Recommendation。这意味着多设备 passkey 这一整套行为终于从「浏览器各自实现」变成了稳定标准(新特性会进 Level 4)。对开发者的意义很直接:可以放心依赖,不用再为每个版本差异写兼容分支;
  • FIDO Alliance 发布了 Credential Exchange Protocol(CXP)与 Format(CXF),定义了凭据在管理器之间搬运的标准格式。今年 7 月,包括几家主流密码管理器在内的厂商联合宣布支持;
  • 据公开报道,苹果也在 iOS 26 / macOS 26 这一代系统里加入了 passkey 的导入导出

跨生态迁移这条最难的路,第一次有了标准答案。

# 一些只有接了线才会知道的事

challenge 必须在服务端生成且一次性。 我第一版图省事把它存在了 localStorage 里,等于自己把抗重放的唯一防线拆了。

rpID 决定了你的 passkey 作用域。 它必须是当前域名的可注册后缀。app.example.com 可以把 rpID 设为 example.com,这样 www.example.com 也能用同一批凭据。但如果你想让两个完全不同的域名共用一套 passkey(比如换了主域),就得用 Related Origin Requests:在 example.com/.well-known/webauthn 上放一份 JSON,列出允许的 origin。

{
  "origins": ["https://app.example.com", "https://www.example.com"]
}

attestation 基本不用开。 除了企业场景需要确认硬件型号,普通站点设 attestation: "none" 就好——开了反而会让部分用户看到额外的隐私授权弹窗,而且为了隐私,很多 authenticator 返回的 AAGUID 是全零。

跨设备登录(hybrid)是自动的。 手机不在手边时,用户可以用电脑上的二维码扫手机完成认证,走的是蓝牙 + 距离校验。你不需要写代码,只要别把 transports 写死成 internal

登录流程改造要渐进。 我的顺序是:先让已有用户在「安全设置」里自助添加 passkey(这时候信任成本最低),跑一段时间,再把登录页的默认路径换成 conditional UI,密码保留为兜底。一次性切换的结果一定是客服工单爆炸。

# 密码不会在某一天消失

写完之后我的判断是这样的:

passkey 是一个罕见的、安全和体验不冲突的升级——更快(点一下 vs 输 12 个字符)、更安全(抗钓鱼、服务器无秘密可泄露)、还省掉了短信验证码的钱和延迟。这种好事在安全领域不多见。

但 FIDO 那份报告里还有一条冷静的数据:57% 的组织,员工日常登录仍然依赖可被钓鱼的方式。密码的退场不会是一个开关,而会是一个很长的渐变——共享设备、老年用户、客服代登、遗留系统、极端合规场景,都会让它继续存在好些年。

对个人来说,这件事其实很简单:在支持的地方,把 passkey 开上。它不会让你更安全到无懈可击,但它把你从「记住几百个秘密」这件人类本来就不擅长的事里,解放出来了。

至于我那个密码管理器——我还没删。里面有四十多个站点不支持 passkey,其中一个是我交电费的地方。