短信验证最佳实践:设计经得起考验的 OTP 流程

2026/01/16

短信一次性验证码是互联网上部署最广的第二因素,也是仍在大规模使用的最弱的那一个。NIST 自 2016 年起就把 SMS 列为受限的认证方式,而它至今无处不在,只因为它是几乎每个用户都已经拥有的唯一因素。

如果你在建设或运营一套 OTP 流程,实际问题不是「短信是不是理想方案」,而是「怎么运营它,才能让失败模式是你自己选择的那些」。本文讲的是防御性设计,不涉及绕开任何人的保护机制。

简要威胁模型

请针对下面这些具体项做设计,而不是笼统地防「欺诈」:

  • SIM 交换与号码劫持 —— 攻击者说服运营商把受害者的号码转到自己设备上。你的 OTP 流程检测不到这件事,只有近期性信号与升级验证能起作用。
  • 实时钓鱼中继 —— 代理站点从受害者处取得验证码,并在有效期内重放。这是当前针对短信 OTP 的主流攻击,验证码生命周期是你对抗它的主要杠杆。
  • 设备被控时的验证码窃取 —— 通知预览、拥有短信权限的恶意应用、跨设备同步的消息记录。
  • 暴力破解与重放 —— 猜测 6 位验证码,或复用已截获的验证码。
  • 账号枚举 —— 用 OTP 接口探测哪些手机号存在账号。
  • 短信泵送(话费欺诈) —— 攻击者驱动大量发送到自己能分成的高费率号段。这一项直接烧你的钱,而且通常直到账单来了才被发现。

真正有效的限流

大多数限流失效,是因为只在一个维度上生效,而攻击者会轮换其他维度。

**同时按多个键限流。**按手机号、按账号、按 IP、按设备指纹,以及按目标国家的全局配额。轮换 IP 的攻击者仍会撞上按号码的上限;轮换号码的会撞上按 IP 的上限。

**用渐进延迟,而不是一堵硬墙。**逐级退避——30 秒、2 分钟、10 分钟——能压制自动化攻击的吞吐量,同时不会把真的输错了的用户直接锁死。

**按目标国家设上限。**短信泵送集中在特定号段。给每个国家设发送上限并在接近时告警,能在账单变大之前抓到它。

**在异常时加摩擦,而不是按量加。**每次请求都上验证码,只会训练用户无视它;只在风险分越过阈值时上,才保住了它的信号价值。

**把发送限额与验证限额分开。**这是两种不同的攻击。发送要花钱、会被泵送利用;验证尝试是暴力破解。在少量失败尝试后(常见上限是 5 次)锁死验证码,这与你允许多少次发送无关。

验证码的生命周期、熵与绑定

**保持短生命周期。**是分钟级,不是小时级。这个窗口是你对抗实时钓鱼中继的主要防线:五分钟过期的验证码,就是给攻击者五分钟。更短更安全,但短于两分钟左右,就会开始因为慢路由的用户而产生客服压力。

**一次性使用,重发即失效。**用户请求新验证码时,旧的必须立刻作废。让先前的验证码继续有效,只是在毫无收益的情况下成倍放大攻击面。

**至少 6 位。**配合 5 次尝试上限,6 位数已经让暴力破解不可行。4 位验证码在宽松的尝试预算下是可以猜出来的。

**把验证码绑定到上下文。**记录它是为什么签发的,并在核销时校验:

  • 动作(注册 vs 登录 vs 重置密码 vs 支付确认)
  • 发起请求的会话或设备
  • 规范化为 E.164 的手机号与地区

其中绑定动作最关键。一个用于确认修改邮箱的验证码,不应该能用来授权一笔转账。

在短信正文里说明这条验证码是做什么的。「123456 是您确认 400 美元转账的验证码」给了钓鱼受害者一个察觉不对劲的机会。孤零零六个数字什么也给不了。

投递 UX:客服工单的来源

用户对 OTP 的感受是二元的——要么好用,要么产品坏了。而这种感受大部分是界面塑造的,不是投递决定的。

明确等待预期。「验证码通常在 30 秒内送达」能阻止重发螺旋,而重发螺旋本身会触发限流,让投递真的变差。

**重发按钮配可见倒计时。**一个带计时的灰色按钮,能阻止那个最伤用户自己的行为。

**报错不要泄露账号是否存在。**标准写法是「如果该号码存在账号,我们已发送验证码」。区分「无此账号」与「已发送」,等于免费送给攻击者一个枚举探针。

**支持粘贴与自动填充。**输入框加 autocomplete="one-time-code",短信正文用绑定来源的 WebOTP 格式。把验证码拆成六个独立小格子还禁止粘贴,既是可用性问题也是可访问性问题。

**提供兜底路径。**语音电话、邮箱,或引导启用认证器。某些运营商与地区组合会静默失败,而一个没有备选的用户会直接离开。

**永远不要展示完整号码。**任何确认界面上都只显示末 2 到 4 位。

按地区与运营商做监测

聚合投递成功率这个数字,会掩盖每一个值得发现的问题。

目标国家与运营商分别追踪发送成功率、送达回执与验证完成率。上游路由变更的表现形式是某一个地区塌陷、而全局均值几乎不动。告警应该基于各分段相对自身基线的偏离,而不是总数。

另一个值得追踪的指标是验证耗时分布。p95 上升通常意味着某条路由在彻底失败之前已经在劣化。

数据保留:少存

只保留运营客服与调查事件所必需的:

  • **该留:**时间戳、投递状态、目标国家、号码的哈希或截断引用,以及验证结果的审计轨迹。
  • **别留:**超出验证窗口的、含有有效验证码的短信正文。运营上没有理由,出事时全是负债。
  • **验证码落库要哈希。**明文躺在数据库或日志里的验证码是纯粹多余的暴露。记录它存在过,永远不要记录它的值。
  • **设置真正的 TTL。**永久留存的验证记录,会替你攒出一个你本没打算建立的手机号数据集。

USPhoneGen 的做法:隐私政策 · 服务条款

使用虚拟号码的用户

你会有相当一部分用户使用虚拟号码或在线接码来验证——出于隐私、测试,或者人在国外。你的系统应该在保持安全的同时,不把他们默认当成有罪。

**明确决定你的线路类型政策,并把它说出来。**如果你屏蔽 VoIP 号码,就在报错里写清楚。对一个确定性的策略拒绝返回笼统的「验证失败」,会把用户推进一个永远不可能成功的重试循环,最后落到你的客服队列里。

**不要惩罚运营商延迟。**地区差异是正常的。超时与重试逻辑应该容纳慢路由,而不是把它当作欺诈信号。

**把线路类型当作若干输入之一。**号码历史、IP 信誉、设备信号与频率都携带信息。一刀切屏蔽 VoIP 是一把钝器——对支付流程是对的,对订阅邮件通常是错的。

**在关键时刻复查,而不是持续复查。**在提现或修改凭据时再验一次是相称的。对一个休眠账号反复复查并把它锁死,是你失去无辜用户的方式。

线路类型分类的原理,以及它为什么会造成你的用户报告的那些拒绝:Non-VoIP 与 VoIP 号码

规划离开短信的路径

短信 OTP 是地板,不是终点。设计时就要留出迁移用户的能力:

  • 在短信之外同时提供 TOTP 或 passkey,并在登录成功之后提示启用,而不是在注册时——注册环节增加摩擦会伤转化。
  • **绝不让短信静默覆盖更强的因素。**如果用户已有 passkey,把短信作为无条件兜底,等于把他们的安全等级降回短信。
  • **把更换号码当作高风险事件。**要求提供现有因素,并在新号码可用于账号找回之前设置冷却期。

常见问题

OTP 应该多久过期?
2 到 10 分钟,5 分钟是个合理默认值。更短能压缩钓鱼中继窗口;太短则会让慢路由上的用户频繁失败。

应该允许多少次验证尝试?
每条验证码 5 次左右,然后作废并要求重新发送。同时按账号单独统计失败次数,以捕捉分布式猜测。

我该屏蔽 VoIP 号码吗?
取决于这个账号能做什么。涉及支付或受 KYC 约束的,应该屏蔽。面向大众的普通产品,一刀切屏蔽会赶走正当用户——把线路类型与其他信号一起权衡更合适。

单项价值最高的控制措施是什么?
带按国家发送上限的多维度限流。它同时应对暴力破解、账号枚举与话费欺诈。

2026 年还值得部署短信 OTP 吗?
作为「什么都没有的用户」的兜底基线,值得。作为你提供的最强因素,不值得。上线它的同时,给出通往 TOTP 或 passkey 的路径。

怎么阻止短信泵送?
按国家设发送上限并告警、屏蔽你根本不服务的目标号段、对新账号施加渐进摩擦。盯着按国家分的「每个验证成功用户的成本」——泵送最先在这里露头。

参考资料

延伸阅读

管理员

管理员