告别短信验证码账单:vibe 项目的 Passkey(WebAuthn)无密码登录实战
短信验证码按条烧钱、还防不住劫持。Passkey(WebAuthn)用设备端私钥签名替代短信:7 步跑通最小闭环,5 条安全红线一次讲透,外加多设备管理、丢失恢复链路与 8 项上线 checklist——把登录成本和整类攻击一起归零。

核心观点:短信验证码是"能跑但一直在烧钱"的登录方案,Passkey 是把它连根拔掉
站内之前发过一篇《别让验证码短信把你发破产》,讲的是"既然用了短信 OTP,怎么限流防刷、把账单压住"。那篇的立场是防守——短信通道留着,但别被薅秃。这篇的立场是进攻:如果你的用户设备是近三年的手机或电脑,直接上 Passkey(WebAuthn),把短信验证码这条线连根拔掉。没有按条计费的短信账单,没有验证码被转述、被劫持的风险,私钥压根不在你的服务器上,想拖库都没得拖。
先说清楚 Passkey 不是什么"更酷的登录按钮"。它的本质是浏览器原生支持的公钥认证:私钥锁在用户设备的 Authenticator(手机安全芯片、系统钥匙串、Windows Hello)里永不外出,服务端只存公钥;每次登录服务端发一个一次性 challenge,设备用私钥签名,服务端用公钥验签。用户感知到的就是"指纹一按就进去了",你感知到的是整类攻击(密码撞库、验证码劫持、钓鱼套码)直接消失,而不是"又多了一道防线"。
一、三种无密码路线对照:先选对,再动手
无密码登录主流就三条路线:Passkey、Magic Link(邮箱链接)、短信 OTP。别凭感觉选,看决策表:
| 维度 | Passkey | Magic Link | 短信 OTP |
|---|---|---|---|
| 单次登录边际成本 | ≈0,无第三方费用 | 低,邮件发送费可忽略 | 高,短信按条计费 |
| 抗钓鱼 | 强:origin/rpId 绑定,假域名签不出有效签名 | 弱:链接可被转发,钓鱼页可套取点击 | 弱:验证码可被诱骗转述,SIM 卡可被劫持 |
| 防劫持 | 强:challenge 一次性 + 私钥签名 | 中:token 一次性但传输链路可被截获 | 弱:短信明文传输,运营商链路不可控 |
| 登录步骤 | 1 步:生物识别确认 | 3 步:输邮箱 → 切邮箱 → 点链接 | 3 步:输手机号 → 等短信 → 输码 |
| 跨设备 | 强(系统钥匙串同步)/ 弱(单设备密钥) | 强,任意设备收邮件 | 强,任意手机收短信 |
| 落地复杂度 | 中,前后端都要写 WebAuthn 逻辑 | 低 | 低 |
把上表量化成 5 分制(分越高越好,口径:成本=单次登录边际成本,安全=抗钓鱼与防劫持,体验=登录步骤顺滑度),就是下图:登录体验 Passkey 5 分、Magic Link 3 分、短信 OTP 3 分;安全性 Passkey 5 分、Magic Link 3 分、短信 OTP 2 分;成本(越省钱分越高)Passkey 5 分、Magic Link 4 分、短信 OTP 1 分。短信 OTP 的成本项只有 1 分——这就是它最大的原罪:体验没赢,安全没赢,钱还一直在烧。
" alt="三种无密码路线对照条形图:登录体验、安全、成本三个维度,Passkey 均为 5 分,Magic Link 为 3/3/4 分,短信 OTP 为 3/2/1 分">图 1:三种无密码路线对照。Passkey 在体验、安全、成本三个维度全部拿满 5 分;短信 OTP 的成本项只有 1 分——体验没赢、安全的 2 分是三条路线里最低的,钱还一直在烧。结论很直接:新项目默认选 Passkey;"邮箱即账号"的轻项目可以用 Magic Link;短信 OTP 只配做兜底。
选型结论就一句话:新项目默认 Passkey;已经跑起来的项目,把 Passkey 做成首选登录方式,短信 OTP 降级为兜底。Magic Link 适合"邮箱即账号"的轻量项目(比如内部工具、Newsletter 类产品),它的实现成本最低,但体验上有个硬伤——用户必须在应用和邮箱之间来回切换,移动端尤其割裂。下面进入实战:把 WebAuthn 的最小闭环跑通。
二、WebAuthn 最小闭环:7 步 + 服务端伪代码
先把原理压缩成一句话:注册时设备生成密钥对,私钥永不离开 Authenticator,公钥存你库里;登录时你发一次性 challenge,设备签名,你验签。整个闭环拆成 7 步,前 3 步是注册(create),后 4 步是登录(get)。下图把这 7 步展开成了 12 个具体请求/响应,并标出了两个安全检查点,先看图再看文字:
" alt="Passkey 注册与登录时序图:注册阶段 6 步经浏览器、前后端、Authenticator 完成密钥对生成与落库;登录阶段 6 步完成 challenge 签名校验与会话签发;含安全检查点 A(challenge 生成)与 B(服务端校验)">图 2:Passkey 注册与登录时序图(WebAuthn 最小闭环)。注册阶段:前端请求 options → 服务端返回 challenge/rpId/user.id → 浏览器调 navigator.credentials.create() 唤起生物识别 → Authenticator 生成密钥对并返回公钥/credentialId → 前端回传 verify → 服务端验签后落库(credentialId、公钥、signCount)。登录阶段:前端请求 options → 服务端返回新 challenge → 浏览器调 navigator.credentials.get() → Authenticator 对 challenge 签名返回 assertion → 前端回传 verify → 服务端校验通过后签发会话。安全检查点 A:challenge 必须服务端随机 ≥32 字节、一次性使用、用后即焚、5 分钟过期、与会话绑定。安全检查点 B:challenge 一致且一次性、origin/rpId 精确匹配、验签通过、signCount 递增。底线:私钥永不离开 Authenticator,服务端只存公钥。
注册阶段(create)。第 1 步:前端 POST /webauthn/register/options,服务端生成 options——随机 challenge(≥32 字节)、rpId(你的域名,注意是域名不是完整 URL)、user.id(用户唯一标识,建议用内部 UUID 别用邮箱明文)。challenge 必须存服务端(Redis 最合适,5 分钟 TTL),且与当前会话绑定,这是防重放的第一道锁。第 2 步:浏览器 navigator.credentials.create(options) 唤起系统弹窗,用户指纹/面容确认后,Authenticator 在设备内生成密钥对,私钥锁死在安全芯片/钥匙串里,公钥 + credentialId 返回给前端。第 3 步:前端 POST /webauthn/register/verify,服务端做三件事——challenge 对得上且没用过、origin 精确匹配(字符串全等,不是 contains)、验签通过;然后落库:credentialId、公钥、初始 signCount。
登录阶段(get)。第 4 步:前端 POST /webauthn/login/options,服务端生成新 challenge(同样一次性 + 5 分钟过期,绝不能复用注册时的)。第 5 步:浏览器 navigator.credentials.get(options),用户生物识别确认,Authenticator 用私钥对 challenge 签名,返回 assertion。第 6 步:前端 POST /webauthn/login/verify,服务端走检查点 B:challenge 一致且一次性、origin/rpId 精确匹配、公钥验签通过、signCount 递增且大于库里旧值(这一步是防克隆的,下一节细讲)。第 7 步:校验通过后签发会话(Set-Cookie 或 Token),后续请求走常规鉴权——Passkey 只负责"证明你是你",会话管理还是你原来的那一套,不用重写。
服务端伪代码(Python 风格,生产环境请用成熟库,见第三节点 5):
# 注册:生成 options(对应第 1 步)
challenge = secrets.token_bytes(32) # ≥32 字节真随机
redis.setex(f"webauthn:reg:{session_id}", 300, challenge) # 5 分钟过期
options = {
"challenge": b64url(challenge),
"rp": {"id": RP_ID, "name": APP_NAME}, # RP_ID = 你的域名,如 example.com
"user": {"id": user_uuid, "name": username, "displayName": username},
"pubKeyCredParams": [{"type": "public-key", "alg": -7}], # ES256
"authenticatorSelection": {"userVerification": "preferred"},
}
# 登录:校验 assertion(对应第 6 步,检查点 B)
saved = redis.getdel(f"webauthn:login:{session_id}") # 取后即删:一次性
assert saved and saved.challenge == client.challenge, "challenge 不一致或已用过"
assert client.origin == EXPECTED_ORIGIN and client.rpId == RP_ID, "origin/rpId 不匹配"
verify_signature(credential.public_key, client.challenge, assertion.signature)
assert assertion.sign_count > credential.sign_count, "signCount 未递增,疑似克隆"
credential.sign_count = assertion.sign_count # 更新计数器
issue_session(user_id) # 第 7 步:签发会话
注意第 6 步里 redis.getdel 的细节:challenge 的"一次性"必须靠原子操作保证——"读出来就删掉",而不是"读出来、校验完、再删"。后者在高并发下有竞态窗口,同一个 challenge 会被验两次。Redis 的 GETDEL、数据库的"标记已用 + 唯一约束",都是正确姿势;"校验通过后再删"不是。
三、安全红线:5 条,碰一条就等于没做
WebAuthn 的安全是"默认给你"的,但前提是服务端那 5 个检查点一个不落。下面 5 条是红线,不是建议——碰一条,整个 Passkey 登录就约等于没做:
红线 1:challenge 必须服务端随机、一次性、用后即焚。随机源用系统级 CSPRNG(secrets.token_bytes / crypto.randomBytes),长度 ≥32 字节;存 Redis 设 5 分钟 TTL;校验时用 GETDEL 这类原子操作"取后即删"。最常见的翻车是"challenge 前端生成"或"challenge 复用"——AI 生成的代码尤其喜欢把 challenge 写成固定值方便调试,上线前必须 grep 掉。challenge 可预测 = 签名可重放 = Passkey 白做。
红线 2:origin 与 rpId 精确匹配,字符串全等。这是 Passkey 抗钓鱼的命门:签名里绑定了 rpId,钓鱼站点的域名对不上,验签直接失败。校验时写 client.origin == EXPECTED_ORIGIN,不要写 contains、不要写正则、不要"为了方便测试"加通配符。预发布环境和生产环境用不同的 rpId(auth-staging.example.com vs example.com),两个环境的 credential 不能混用——这正是 rpId 隔离的价值,别为了省事复用生产 rpId 做测试。
红线 3:signCount 必须递增校验,异常直接告警 + 冻结。每次登录 assertion 里带一个计数器,正常情况下它单调递增;如果某次传来的 signCount 小于等于库里存的值,说明这个私钥可能被克隆到了第二台设备(两台设备各自递增,计数器就会对不上)。处理策略:拒绝本次登录,给用户发安全通知,并提供"吊销该 credential、重新注册"的入口。注意 iCloud 钥匙串同步的 passkey 在多设备同步场景下 signCount 可能为 0 或不递增——所以这条的落地是"异常告警 + 人工复核",不是"直接永久封号"。
红线 4:userVerification 按场景选,别一刀切。preferred(有生物识别就用,没有就退回 PIN/图案)适合普通登录,覆盖率最高;required(强制生物识别/PIN)留给支付、改密码、吊销设备这类敏感操作;discouraged 几乎不要用——它等于主动放弃"证明是本人",退化成"证明有这台设备"。一个实用模式:日常登录用 preferred + conditional mediation(自动填充,用户连按钮都不用点);进"账号安全设置"页时 step-up 成 required。
红线 5:不自建 crypto,验签与 attestation 解析一律用成熟库。Node 用 @simplewebauthn/server,Python 用 webauthn(Duo 出品),Go 用 go-webauthn/webauthn。自己拼 COSE 公钥、自己解析 CBOR attestation、自己实现 ES256 验签,是给自己挖坑——这些库已经把 rpId hash 比对、aaguid 解析、证书链验证这些脏活干完了。你的代码应该只剩三件事:生成并暂存 challenge、调库 verify、落库/更新 credential。凡是看到手写的 crypto.verify 裸调,先打个问号。
四、多设备管理:注册、丢失、合并,老用户一个不能少
Passkey 落地最容易被低估的不是 WebAuthn 协议本身,是"人"的部分:用户有 3 台设备、丢过 1 台手机、还有个 2 年前用密码注册的老账号。这三件事没想好,上线第一天客服就被问爆。
多设备注册:一个账号 N 个 credential,独立管理。账号设置页放一个"添加此设备为 Passkey"按钮,走一遍注册 3 步即可;数据库里一个 user.id 对应多行 credential(credentialId、公钥、signCount、设备昵称、创建时间)。每行可单独命名("MacBook Pro"、"Pixel 9")、单独吊销。建议在用户注册第 2 个 passkey 时给个轻提示:"已在 2 台设备启用,丢了一台也不怕"——这是在帮用户建立恢复意识,成本为零。
设备丢失恢复链路:恢复手段必须在丢手机之前就准备好。这是 Passkey 方案里真正的单点故障:私钥不出设备,手机丢了 = 那个 credential 永远没了。所以恢复链路不是"事后补救",是"事前配置":① 引导用户至少注册 2 个 passkey(手机 + 电脑);② 备用恢复方式用邮箱 Magic Link,不要用短信 OTP——否则整个方案的安全水位会被拉回到短信那条线,等于白切;③ 恢复流程本身加保护:新设备注册后 24 小时内禁止敏感操作(改密码、提币、删账号),并给账号绑定的邮箱发通知"你的账号在新设备上恢复了登录,非本人操作请立即冻结"。演练一次完整的"丢手机 → 邮箱恢复 → 吊销旧 credential"流程,掐表确认能在 5 分钟内走完,再上线。
与现有密码/OTP 账号合并:别搞一刀切迁移。老用户已经在库里了,策略是"登录后升级":用户用原来的密码/OTP 登录后,在设置页看到"升级为 Passkey 登录,一键启用",点一下走注册 3 步,credentialId 绑定到已有的 user.id 上——账号体系不动,只加一种登录方式。过渡期保留密码 + OTP 登录入口,同时埋点看 Passkey 登录成功率;当 Passkey 登录占比连续 4 周超过 80%、失败率低于 2% 时,再弹窗引导用户关闭密码登录(别强制,给用户选择权,但把"更安全、更快"的利益点说清楚)。最忌讳的是"新用户只能 Passkey、老用户强制迁移"——前者把低版本设备用户挡在门外(见第五节降级),后者把客服通道打爆。
五、兼容性矩阵与降级策略:Passkey 不是 100% 覆盖,但失败时必须优雅
先给兼容性交个底(2026 年下半年的现实):iOS 16+ / macOS Ventura+(iCloud 钥匙串同步开箱即用)、Android 9+(Google 密码管理工具)、Windows 10 22H2+(Windows Hello)、Chrome / Edge / Safari / Firefox 近两年版本——这几块加起来,覆盖了绝大多数真实用户。但"绝大多数"不是"全部":老旧 Android、公司配的锁死浏览器的 Windows 7、隐私模式禁了系统钥匙串的用户,都会走到 navigator.credentials 不存在或 conditional mediation 抛异常的分支。
降级策略三条,按优先级排:第一,检测到不支持 WebAuthn,直接降级到 Magic Link,而不是短信。理由在第一节已经算过账了:Magic Link 成本 4 分、体验 3 分,短信成本 1 分——降级链路本身也别烧钱。前端检测很简单:window.PublicKeyCredential 是否存在,不存在就隐藏 Passkey 按钮、展示"邮箱链接登录"。第二,conditional mediation(自动填充)失败时,给显式按钮。自动填充是最丝滑的形态(用户名框直接提示"用 Passkey 登录"),但部分浏览器/iframe 场景下会被禁;catch 住异常后渲染一个明确的"使用 Passkey 登录"按钮,别让用户对着没反应的输入框发呆。第三,埋点看降级率,超过 15% 就暂缓把 Passkey 设为默认。降级率 = 走降级链路的登录次数 / 总登录次数。如果你的用户群设备偏老(比如目标用户是中小商户的收银平板),Passkey 就先做"可选的更快方式",别做默认——强推一个 30% 用户用不了的登录方式,是产品事故。
还有一个跨平台细节:rpId 决定 passkey 的作用域。rpId 设成 example.com,app.example.com 和 www.example.com 都能用同一个 passkey;但 rpId 设成 app.example.com,主站就用不了。所以 rpId 取你的根域名,一开始就定好——rpId 一旦确定就不能改,改了等于让所有用户重新注册。
六、上线 checklist:8 项,全绿再发版
对照着一项一项打勾,有一项是红的就别上线:
1. rpId 与线上域名一致。预发布用独立 rpId(如 auth-staging.example.com),禁止复用生产 rpId 做测试;上线前确认生产配置里的 RP_ID、EXPECTED_ORIGIN(含 scheme + host + port)与真实域名逐字符一致。
2. challenge 链路正确。≥32 字节真随机、Redis 存储 5 分钟 TTL、校验用原子"取后即删"、与会话绑定。grep 代码确认没有固定 challenge、没有前端生成的 challenge。
3. origin 白名单精确匹配。字符串全等比较,无 contains、无正则、无通配符;顺手确认 CORS 配置没有把恶意源放进来。
4. signCount 递增校验 + 异常告警。小于等于库中值时拒绝登录、发安全通知、提供吊销重注册入口;告警要进你的 on-call 通道,别只记日志。
5. attestation 策略明确。起步用 none(不验证设备证书,兼容性最好);金融、支付类场景再上 direct 并维护可信 aaguid 列表。策略要写在文档里,别让后人猜。
6. 多设备与恢复链路实测。注册第 2 个 passkey、吊销单个 credential、"丢手机 → 邮箱 Magic Link 恢复 → 吊销旧 credential"全流程走一遍并掐表,5 分钟内走完;恢复后 24 小时敏感操作冻结 + 邮件通知已验证。
7. 降级链路可用且有监控。window.PublicKeyCredential 不存在时自动切 Magic Link;降级率 dashboard 上线,超 15% 告警并人工复核是否暂缓默认启用。
8. 会话与通知收尾。Passkey 校验通过后签发新会话,同时吊销该用户的旧会话 token(防"旧设备还登着");首次 Passkey 登录成功、新增设备、吊销设备三类事件都要发邮件/推送通知。
结尾判断:Passkey 是把"验证用户"从你的账本上划掉
最后把账算清楚。短信 OTP 的模式是:每多一个用户、每多一次登录,你就多一份成本和多一份风险——短信费线性增长,验证码劫持的攻击面永远都在。而 Passkey 的模式是:私钥在用户手里,验证在数学里完成,你的服务器上从此没有密码库可拖、没有短信费可烧、没有"验证码被转述"的工单可回。
对 vibe 项目来说,这个取舍尤其划算:人少,安全预算更应该花在"让整类攻击消失"上,而不是"把每类攻击挡回去"。落地路径也别贪多:先用两周跑通第二节的 7 步最小闭环(注册 3 步 + 登录 4 步),再按第六节的 checklist 一项一项补齐,短信通道只留作降级兜底——注意是 Magic Link 优先、短信最后。当你看到短信账单归零、登录转化率反而上升的那天,就会明白:最好的验证码,是根本不需要发出去的那一条。
评论 (0)
相关文章

没有后台的上线项目,第一次运维请求就会把你逼回 ssh 手敲 SQL。这篇指南从“上线第 1 天”的功能 MVP 清单讲起:用户/订单/内容/配置四类实体的后台功能矩阵,super_admin/operator/support 三档权限与三层隔离,高危动作的人审二次确认,追加写不可篡改的审计日志,impersonate 的限时 token 安全链路,以及 Retool/自建低代码选型对照和 8 项上线 checklist。

console.log 里躺着用户邮箱和登录 token?这篇指南给你一人团队一天能落地的日志脱敏全方案:PII 四级分级表、可直接复制的 TypeScript 脱敏三件套、console.log 到 pino 的最小迁移、请求日志中间件模板、Sentry beforeSend 配置、分级保留策略与成本账,外加 8 个真实踩坑。

备份是最后的防线,回收站是第一道。这篇实战指南讲应用层的删除语义:三种删除语义决策表、deleted_at 数据模型、partial 唯一索引、Prisma 查询层防线、回收站保留期分级、GDPR 与个保法删除权对账,以及 8 项上线 checklist。