密码正在退场:Passkey、WebAuthn,和那些没人告诉我的坑
- 作者:Bougie
- 创建于:2026-09-23
上个月我收到一封邮件,开头是那句熟悉的话:「我们检测到您的账户密码可能已泄露,请立即修改。」
我很配合地点进去改了一个,然后顺手打开那个存了几百条记录的密码管理器,看着里面一半以上的条目都带着「建议更换」的黄色小三角。那一刻我意识到一个有点荒谬的事实:我是一个写了十年登录页的人,而我自己的数字生活,仍然建立在「把同一串字符交给几十个网站各自存一份」这个模型上。

真正让我动手的,是 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", // 关键
})

# 服务端到底验了哪几件事
这部分是我接的时候最想有人给我讲清楚的。一次登录,服务端手里的输入只有三段字节:clientDataJSON、authenticatorData、signature。校验大致七步:
clientDataJSON.type必须是webauthn.get(注册时是webauthn.create)——防止拿注册响应冒充登录;clientDataJSON.challenge等于 session 里那个,且用过即删;clientDataJSON.origin等于预期的完整 origin,不能只比域名;authenticatorData前 32 字节是rpIdHash,必须等于SHA256(rpID)——这一条就是抗钓鱼的全部依据;- flags 字节里 UP(用户在场)必须置位,如果请求时写了
userVerification: "required",UV 位也要置位; - 用数据库里存的 COSE 公钥验签:
authenticatorData || SHA256(clientDataJSON); 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,其中一个是我交电费的地方。