密码,本质上是一个共享秘密;而无密码认证技术,本质上是在回答一个更底层的问题——能不能不用“你知道什么”,而用“你拥有什么”或“你是什么”来证明身份。这两年各大平台扎堆推Passkey、推WebAuthn,不是单纯为了省去用户输密码的麻烦,而是在重构一套信任模型。我会用尽量直白的话,把这套模型从原理到落地讲清楚,包括FIDO2、WebAuthn、Passkey之间的边界,服务端和前端要怎么接,以及我在真实项目里踩过的各种坑。这篇内容适合两类人:一类是正在给产品做登录改造的技术负责人,另一类是单纯想搞明白“无密码到底是不是真安全”的开发者。读完你至少能回答一个问题:为什么说密码是个需要被淘汰的旧协议。
1. 为什么我们还在用“共享秘密”来证明身份?
要理解无密码认证,得先接受一个事实:密码这套东西,从设计之初就有一个结构性的弱点——验证双方共享同一个秘密。你把密码告诉服务器,服务器存一个哈希版本,每次登录你把这个秘密原封不动交出来,两边比对一致就算通过。问题在于,这个秘密一旦被复制,你没有任何办法区分“本人在输入”和“攻击者在输入”。
1.1 共享秘密模式的脆弱性
密码泄露的途径实在太多了。网络钓鱼可以让用户在伪造页面上主动交出密码;撞库可以用别的平台泄露的账号密码组合来试你的系统;数据拖库则更直接,攻击者拿到数据库后虽然看到的是哈希,但只要密码足够简单,离线跑字典一样能还原。更麻烦的是,用户几乎必然会在多个平台复用密码,哪怕你的系统做得再安全,用户在其他地方泄露了同一个密码,你的登录入口也等于半开。
我在实际项目里见过最典型的案例:一个内部系统密码强度要求已经做到12位以上,还强制大小写加符号,结果几个月后客服还是收到了好几起“账号被异地登录”的投诉。查下来发现,用户压根没在这个系统里输过密码,是他在某个论坛用了同一个密码,那边被拖库了。你看,密码这个共享秘密的泄露半径,根本不由你的系统掌控。
另一个被低估的问题是服务端责任太重。哪怕你只存哈希,哈希算法选得不好、没加盐、或者用了过时算法,数据库泄露后密码照样能被批量还原。换句话说,在共享秘密模型里,服务端保存的认证数据本身就是攻击价值极高的目标。只要这个模型不变,安全对抗就永远在“攻防双方谁更勤快”上打转。
1.2 从“共享秘密”到“数字誓言”的转变
无密码认证的核心思路,是把“我知道一个秘密”换成“我签署一条断言”。类比一下就是:以前你到门卫那儿报一个口令,门卫也知道这个口令;现在你出示一张带有你私钥签名的卡片,门卫只需要用公开的验证密钥就能确认这张卡片是否由你签发。你的私钥永远不离开你的设备,服务器端也不需要保存任何和你私钥等价的数据,它只保存一把公钥,用来验证你的签名。
这就是我标题里说的“数字誓言”。每次登录,你的设备并不是把密钥发给服务器,而是对服务器下发的随机挑战做一次数字签名。服务器验证签名对应的公钥确实是这个账号注册时留下的公钥,并且挑战值有效,就认为“此刻持有私钥的人就是账号主人”。在这个过程里,没有任何可复用的共享秘密出现在网络上,截获一次签名报文并不会让攻击者下次能伪造签名。
这套机制靠的是非对称加密。私钥存储在设备的安全区域里,比如手机的Secure Enclave、电脑的TPM芯片、或者独立安全密钥的硬件内部。应用层能调用认证器完成签名,但读不出私钥原文。所以哪怕设备中了木马,攻击者拿到了应用内存,也拿不到能持续使用的身份凭据。这就是从“共享秘密”到“数字誓言”的本质区别:秘密不再需要被分享,誓言只需要被验证。
2. 无密码认证的核心协议与组件
聊原理容易,落到技术栈就会发现一堆名词:FIDO2、WebAuthn、CTAP2、Passkey、认证器、条件UI……我第一次上手的时候也绕晕了。其实拆开看,每一层职责都挺清楚。
2.1 FIDO2、WebAuthn、CTAP2 到底各管什么
FIDO2是FIDO联盟推的整体认证框架,里面包含两个核心部分:浏览器端调用的WebAuthn JavaScript API,以及外部安全密钥与设备通信的CTAP2协议。WebAuthn是W3C制定的标准,规定网站怎么通过浏览器让用户完成注册和登录认证;CTAP2则解决的是当认证器是独立USB/蓝牙设备时,浏览器怎么和这个硬件安全密钥交换指令。
你可以把它们理解成:WebAuthn是“应用层的通用语言”,CTAP2是“设备层的通信管道”。如果认证器直接集成在笔记本电脑或手机里,浏览器可以绕过CTAP2,直接通过平台自身的接口操作本地安全芯片;如果接一个YubiKey这类独立硬件,浏览器就要通过CTAP2跟它交谈。无论是哪种情况,网站开发者主要打交道的是WebAuthn,因为代码层统一被封装成了navigator.credentials.create()和navigator.credentials.get()两个方法。
下面这张表可以帮你快速对齐概念:
| 名词 | 层级 | 作用 | 典型场景 |
|---|---|---|---|
| FIDO2 | 框架 | 定义无密码认证的完整协议族 | 兼容WebAuthn + CTAP2的认证方案 |
| WebAuthn | 浏览器API | 网页向认证器发起注册/认证请求 | 网站集成Passkey登录 |
| CTAP2 | 设备协议 | 浏览器与外部安全密钥之间的通信 | USB/蓝牙/NFC安全密钥 |
| Passkey | 产品形态 | 可同步、可跨设备的WebAuthn凭据 | 手机/PC上的系统级通行密钥 |
2.2 通行密钥Passkey:从“设备本地”到“云同步”
最早的FIDO2实现有一个让产品经理崩溃的缺陷:密钥对绑定在单一设备上。你在笔记本上注册了一个安全密钥,换个手机登录就得重新注册。为了这事,用户走客服流程的概率比原来忘记密码还高。后来苹果、谷歌、微软联合推动的Passkey解决了这个体验问题——Passkey本质上仍然是WebAuthn凭据,但它允许私钥通过平台生态进行端到端加密同步。
听起来好像又把私钥传到了云端,但关键在于同步链路是端到端加密的,平台服务商只能同步密文,拿不到原始私钥。真正执行签名时,依旧发生在你当前设备的Secure Enclave里。换句话说,你的私钥从“只住在一台设备上”变成了“住在一个你信任的设备集群里”,但依然是你的设备持有,服务器端依然只存公钥。
Passkey落地的时候有一个必须注意的点:同一把Passkey在iPhone上注册后,如果你开了iCloud钥匙串,它会同步到Mac、iPad;但如果用户关闭了钥匙串同步,这个凭据就变成设备本地凭据,换设备就发现“Passkey不见了”。开发者在做兼容设计时,不能想当然地认为所有Passkey都能跨设备同步,还要提供备用的认证入口。
2.3 生物识别真的等于密码吗?
很多人第一次用Face ID登录App时会问:我的脸是不是变成密码了?不是。生物识别在无密码认证体系里只是一个“本地解锁器”。你的指纹或人脸数据不会离开设备,更不会发给服务器。真正参与网络认证的,是设备安全区里那把私钥。生物识别的作用是确认“你本人此刻正拿着这台设备”,然后授权私钥执行签名。
所以一次Passkey登录,实际上包含了多重要素的叠加:你拥有这台设备(拥有因子),你通过了生物识别(固有因子),在需要时还可能验证设备PIN(知识因子)。服务器只看到一条由私钥签名的断言,但它背后的设备端其实已经完成了多因子验证。这也是为什么很多安全团队愿意把Passkey认定为一种强认证,而不是简单的“替代密码”。
3. 从一个想法到落地:搭建无密码认证的完整实操
理论说太多容易飘,直接进入落地。我不会讲某个具体云厂商的封闭方案,而是用当前社区用得比较多的开源库来演示完整流程。后端我用Node.js,配合@simplewebauthn/server,前端用@simplewebauthn/browser。这套组合我自己在多个项目里跑过,逻辑清晰,接口设计也接近标准。
3.1 环境准备与依赖选择
首先明确一个硬前提:WebAuthn只在安全上下文中可用。也就是说,线上环境必须走HTTPS,否则浏览器直接拒绝调用认证器。本地开发只有localhost是例外,192.168.x.x这种局域网IP也不行。如果非要局域网测试,得自己签HTTPS证书或者用内网代理,这一步别省。
然后安装依赖:
npm install @simplewebauthn/server@latest @simplewebauthn/browser@latest数据库需要存的数据也比较固定,一个用户表扩展出凭据表就够了,核心字段包括credential_id、public_key、counter、transports和设备名称。提醒一下,counter字段不能省,后面做登录校验时要检查计数器的单调递增,用来防止凭据克隆。
在服务端初始化时需要设置两个关键参数:rpID和rpName。rpID是信赖方标识,一般取当前站点的注册域名,比如example.com,绝不能带https://协议头和端口。rpName是用户在浏览器弹窗里看到的站点名称,比如“示例站点”。这两个参数要和前端调用时的window.location.hostname匹配,否则会报SecurityError。
3.2 注册流程:生成挑战并调用navigator.credentials.create
无密码认证的注册流程可以分成四步:服务端生成挑战、前端创建凭据、前端回传凭据、服务端校验并保存公钥。
第一步,服务端提供一个接口,生成一个随机挑战值,并把它和用户当前会话绑定。挑战值必须是足够的随机字节,建议16字节以上,并且一次性使用。为了用户体验,通常还会同时传userDisplayName、userName这些信息。用generateRegistrationOptions可以一次性搞定:
import { generateRegistrationOptions } from '@simplewebauthn/server'; const options = await generateRegistrationOptions({ rpID: 'example.com', rpName: '示例站点', userName: 'user@example.com', userDisplayName: '张三', timeout: 60000, attestationType: 'none', excludeCredentials: existingCredentialIds, authenticatorSelection: { residentKey: 'required', userVerification: 'preferred', }, });residentKey: 'required'值得单独说。这会让认证器生成“可发现的凭据”,也就是用户下次访问站点时,浏览器能在Passkey选择列表里自动发现这个账号,不用先输入用户名。如果你做的是传统“用户名+Passkey”的流程,可以设成preferred,但配合条件UI最好还是required。
前端拿到这个options后,先进行base64url解码,然后调用startRegistration:
import { startRegistration } from '@simplewebauthn/browser'; const credential = await startRegistration({ optionsJSON: options }); const response = await fetch('/auth/passkey/register/verify', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(credential), });注意,startRegistration返回的对象不能直接塞给后端,@simplewebauthn/browser已经帮我们处理成了“可序列化JSON”结构,所以直接stringify没问题。
后端收到回传的凭据后,再用verifyRegistrationResponse校验签名和挑战。这一步会把响应里包含的clientDataJSON、attestationObject、challenge、origin都验一遍。校验通过后,从registrationInfo里取出credentialPublicKey和credentialID,持久化到用户凭据表里。
import { verifyRegistrationResponse } from '@simplewebauthn/server'; const verification = await verifyRegistrationResponse({ response: credential, expectedChallenge: session.challenge, expectedOrigin: 'https://example.com', expectedRPID: 'example.com', }); if (verification.verified) { const { credentialPublicKey, credentialID, counter } = verification.registrationInfo; await saveCredential({ userId, credentialID, publicKey: credentialPublicKey, counter }); }3.3 登录流程:条件UI与断言验证
登录流程的核心是条件UI(Conditional UI)。用户在登录页时,不需要先输入用户名,浏览器会自动调起系统Passkey选择列表,前提是你的页面输入框上有autocomplete="username webauthn"。前端代码大概这样:
import { startAuthentication } from '@simplewebauthn/browser'; const options = await fetch('/auth/passkey/login/options', { method: 'POST', }).then(res => res.json()); const credential = await startAuthentication({ optionsJSON: options, useBrowserAutofill: true }); const verificationResp = await fetch('/auth/passkey/login/verify', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(credential), }).then(res => res.json());服务端生成登录options时,同样先生成挑战,同时可以返回allowCredentials列表来限定本次允许使用的凭据。如果用户是通过条件UI调起的,浏览器往往会自动选择一个匹配的凭据,allowCredentials可以提供优化,但不强制一定要传。
后端验证时,除了签名和挑战,特别要检查counter。WebAuthn的计数器设计最初是用来防止克隆攻击的:同一把私钥如果被复制到另一个认证器上,旧认证器上的计数器值大概率不会同步增长。所以如果新登录的counter小于等于数据库里保存的值,就要标记异常。实践里,纯软件认证器有时计数器不增长,所以我会把它设成“警告但不强制拒绝”,方便兼容老设备。
3.4 多因子策略配置
接入了Passkey不等于万事大吉。实际操作时,我会根据业务风险等级做分级策略。
低风险操作(比如查看公开信息)可以只做Passkey登录;中风险操作(比如修改昵称)可以要求Passkey加上最近一次登录时间校验;高风险操作(比如修改提现银行卡)就应该在Passkey之外,再叠加一个短期有效的TOTP或设备PIN确认。
服务端可以通过凭据的AAGUID判断认证器级别。AAGUID是认证器型号的唯一标识,如果用户用的是硬件安全密钥,它的AAGUID能对到某个具体产品,安全等级通常比纯软件认证器高。维护一张AAGUID风险等级表,能让你在“无密码”基础上再做一层动态风控,而不是一刀切。
4. 常见问题与排查技巧实录
无密码认证的坑,很多不在协议本身,而在浏览器兼容和用户行为上。我把自己实际处理过的问题整理成了一份速查,希望你能少走弯路。
4.1 浏览器兼容性与Origin问题
目前主流浏览器对WebAuthn的支持已经相当好,但细节仍有差异。
| 浏览器 | WebAuthn Level 2 | 条件UI | 平台认证器 | 备注 |
|---|---|---|---|---|
| Chrome | 支持 | 支持 | 支持 | 有完整的DevTools模拟器 |
| Edge | 支持 | 支持 | 支持 | 和Chrome同内核,行为接近 |
| Safari | 支持 | 支持 | 支持 | 需要macOS/iOS版本较新 |
| Firefox | 支持 | 部分支持 | 支持 | 条件UI支持有滞后 |
最容易踩的坑是rpID没写对。Chrome报的SecurityError通常就是origin和rpID不匹配。记住三点:rpID只能是域名,不带协议、不带端口;expectedOrigin必须带协议和正确端口;页面必须是HTTPS或者localhost。
调试WebAuthn时,Chrome DevTools自带的模拟器非常有用。在Application面板的WebAuthn标签页里,可以启用虚拟认证器,并选择“支持平台认证器”“支持居民密钥”“支持用户验证”等选项。用它来做自动化回归测试,能覆盖80%的兼容性场景,但最终真机测试还是不能省。
4.2 设备丢失和账号恢复
这是所有无密码方案都无法回避的问题:设备丢了怎么办。纯Passkey的话,如果用户没有开启云同步,也没有第二台设备,私钥就真没了。所以设计产品时,必须预设恢复通道。
我在项目里最常用的方案是“Passkey + 一次性恢复码 + TOTP”三件套。用户注册Passkey时,系统生成10个一次性恢复码,要求用户下载保存。恢复码本身是以哈希形式存数据库,使用后立即作废。虽然恢复码看起来像个密码,但它是一次性、高熵、且只能用来进入恢复流程的,二次验证后可以强制设置新的Passkey。
不要用短信验证码作为唯一恢复方式。短信本身存在被劫持或同步到多端的风险,作为降级通道可以,但不能作为无密码系统的兜底。
4.3 跨平台同步不一致
Passkey在苹果生态内同步很顺,到了Android和Windows组合就会出现各种“明明注册了,换个设备却找不到”的情况。原因多半是用户关掉了系统同步,或者凭据本身生成了“不可发现”的类型。
遇到这种问题,先搜索页面端是否有“此浏览器不支持可发现凭据”的错误。如果确认需要跨设备同步,注册时要保证residentKey为required,同时在前端检查credProps里的rk标志:
const credProps = credential.response.getClientExtensionResults(); if (credProps.credProps?.rk) { // 凭据可发现,可以在条件UI中自动弹出 }另外要做好产品文案解释:Passkey可能只保存在用户当前设备或云钥匙串里。不要承诺“所有设备都能看到”,要允许用户在一个设备上注册后,在其他设备手动登录时通过手机扫码或系统蓝牙配对的方式临时调用手机上的Passkey。
4.4 条件UI和自动填充的坑
条件UI的体验很爽,但调试时会发现“弹不出来”。最常见的几个原因:
- 输入框没有加
autocomplete="username webauthn"属性; - 页面在加载时就调用了
startAuthentication,但浏览器不支持条件UI; - 同时调用了多次
navigator.credentials.get,浏览器会拒绝重复请求; - 在iframe里使用,但没有正确配置Permissons-Policy。
我自己遇到最多的反而是最后一种情况。如果登录组件被嵌在商户后台的iframe里,父页面必须给子页面放行publickey-credentials-get权限,否则浏览器会静默失败,连错误都不抛。排查时先用独立的顶层页面测一遍,确认基本流程通,再往iframe里搬。
还有一个小技巧:条件UI的发起时机要放在页面第一个交互之前,但不要阻塞页面渲染。你可以把startAuthentication的Promise挂在一个全局变量里,等用户在输入框聚焦时再await它。这样既能让浏览器提前准备好Passkey列表,又不会挡住用户点击其他按钮。
5. 从踩坑经验看无密码认证的落地价值
我在把内部系统切到Passkey登录时,最大的阻力不是技术,而是运营团队“用户不会用”的担忧。实际上线后发现,只要做好首登引导,绝大多数用户都能在30秒内完成注册,后续登录基本是无感的。真正麻烦的是边缘情况,比如用户在Windows电脑上生成了一把Passkey,后来换了台公司电脑,找不回账号。处理这种问题,只能靠清晰的恢复流程,而不是退回到密码。
如果你的产品还在犹豫要不要上,我的建议是渐进式迁移。先保留原有密码登录,给高价值或活跃用户开通Passkey,同时在后台对比两类用户的登录成功率和客服工单量。等到Passkey占比超过一定阈值,再考虑关闭密码入口。不要一步到位全切,否则用户遇到一两个同步问题,口碑损失比省下的密码成本大得多。
还有一点值得说:无密码认证不是银弹。它能防钓鱼、防撞库、防密码复用,但它防不了“用户主动把Passkey同步到一台被恶意控制的设备上”,也防不了“用户被诱导在同一个受信设备上授权登录”。所以安全架构里始终要保留风控、设备信誉和异常行为检测。把“共享秘密”换成“数字誓言”之后,攻击面确实变小了,但安全运营的工作没有变少,只是换了个方向。