密码本质上是一个共享秘密而无密码认证技术本质上是在回答一个更底层的问题——能不能不用“你知道什么”而用“你拥有什么”或“你是什么”来证明身份。这两年各大平台扎堆推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/serverlatest simplewebauthn/browserlatest数据库需要存的数据也比较固定一个用户表扩展出凭据表就够了核心字段包括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: userexample.com, userDisplayName: 张三, timeout: 60000, attestationType: none, excludeCredentials: existingCredentialIds, authenticatorSelection: { residentKey: required, userVerification: preferred, }, });residentKey: required值得单独说。这会让认证器生成“可发现的凭据”也就是用户下次访问站点时浏览器能在Passkey选择列表里自动发现这个账号不用先输入用户名。如果你做的是传统“用户名Passkey”的流程可以设成preferred但配合条件UI最好还是required。前端拿到这个options后先进行base64url解码然后调用startRegistrationimport { 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与断言验证登录流程的核心是条件UIConditional UI。用户在登录页时不需要先输入用户名浏览器会自动调起系统Passkey选择列表前提是你的页面输入框上有autocompleteusername 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的体验很爽但调试时会发现“弹不出来”。最常见的几个原因输入框没有加autocompleteusername 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同步到一台被恶意控制的设备上”也防不了“用户被诱导在同一个受信设备上授权登录”。所以安全架构里始终要保留风控、设备信誉和异常行为检测。把“共享秘密”换成“数字誓言”之后攻击面确实变小了但安全运营的工作没有变少只是换了个方向。
