一句话回答: 校验验证码不是「比一下相不相等」。一个站得住的实现至少要做到八件事:哈希存储、绑定手机号与用途、原子性一次消费、短有效期、限次加限速、恒定时间比较、错误信息统一、成功后轮换会话。少任何一条,验证码都有被绕过的路径。

一次正确校验的完整顺序
1. 取请求:手机号 + 用途(scope) + 用户输入的码
2. 限速检查:这个手机号 / 这个 IP / 这个账号最近失败了几次
3. 取出该 (手机号, 用途) 下当前未消费的挑战记录
4. 记录不存在 / 已消费 / 已过期 -> 统一返回「验证码无效或已过期」
5. 恒定时间比较 hash(输入 + salt) 与存储的 hash
6. 不相等 -> attempts + 1,达到上限就作废整条记录
7. 相等 -> 原子地把记录标记为已消费
8. 消费成功之后才签发会话,并轮换 session id
顺序本身就是安全的一部分:限速必须在比较之前,消费必须在签发之前。
八个要点
1. 不要明文存验证码
数据库里存 hash(code + 每条记录独立的 salt)。6 位码的空间很小,慢哈希带来的收益有限,真正起作用的是加盐加上严格限次;用 HMAC 配一个服务端密钥也可以。
同样重要的是:日志、APM、错误上报、客服后台里都不能出现明码。生产事故里泄露验证码,一半以上是从日志漏出去的。
2. 把码绑定到手机号和用途上
一条挑战记录的键应该是 (手机号, 用途),用途至少要区分:登录、注册、改密码、改绑手机、支付确认。
经典漏洞:拿注册流程收到的码去调改密码接口,因为服务端只检查了「这个码存在、没过期、没用过」,没检查它是为哪件事发的。
3. 一次性消费必须是原子的
UPDATE otp_challenge
SET consumed_at = now()
WHERE id = ? AND consumed_at IS NULL
判断受影响行数等于 1 才算消费成功。先查询再更新会在并发下被同一个码重复使用——这类竞态在多机部署下必现。用户侧看到的「代码已被使用」是什么意思,见 提示「您输入的代码已被使用」怎么办。
4. 有效期要短,且以服务端时间为准
5 分钟是常见取值,最长不建议超过 10 分钟。为什么必须过期,见 验证码为什么会过期。过期判断只能用服务端时间,客户端传来的时间戳一律不信。
5. 限次和限速要同时做
- 限次:同一条挑战最多试 5 次,超过就把整条记录作废——注意是作废,不是继续返回错误。否则攻击者可以慢慢试。
- 限速:按手机号、按 IP、按账号三个维度分别限,阈值和窗口分开配置。
- 发送侧也要限:不限制发送频率,你的接口就成了别人做 OTP 轰炸的枪,见 OTP 轰炸是什么。
用户看到的「验证尝试次数过多」背后就是这一层,见 限速与解决办法。
6. 用恒定时间比较
用 hmac.compare_digest、crypto.timingSafeEqual 这类函数,不要用 ==。6 位码靠时序侧信道提取的现实难度确实很高,但这是零成本的正确写法,没有理由不用。
7. 错误信息统一,响应时间也要拉平
无效、已过期、已消费、记录不存在——对外全部返回同一句提示和同一个状态码。分开返回等于告诉攻击者「这个手机号在你们这儿有账号」,这是标准的账号枚举漏洞。用户侧看到的各种提示语差异,见 验证码提示无效/不正确/已过期怎么办。
8. 校验通过之后
- 立刻轮换 session id,防会话固定攻击。
- 作废该手机号下其他未消费的挑战,避免旧码还能用。
- 写审计日志:时间、IP、UA、用途、结果。
- 高风险操作再加一道:改绑手机、提现这类动作应该走独立的升级验证,见 升级验证是什么。
生成侧顺带要做对的三件事
- 用密码学安全随机数(CSPRNG),不要用
rand()或时间戳派生。可预测的码等于没有码。 - 位数选 6 位是安全与易用的平衡点,理由见 为什么验证码是 6 位。
- 重发要节流,并且复用同一条挑战:60 秒内点第二次「重新发送」,应该重发同一个码,而不是新建一条记录。新建会导致同时存在多个有效码。
和 TOTP 的区别
短信 OTP 是服务端生成、存储、下发的一次性挑战;TOTP 是双方用共享密钥各自计算的时间窗口码,服务端不存码只存密钥,校验时要容忍时钟漂移窗口,同时要单独防重放(记录已使用的时间步)。两者的机制差异见 TOTP 和 HOTP 有什么区别。
另外要认清一点:短信 OTP 抵御的是密码泄露和撞库(见 撞库是什么),它抵御不了 SIM 交换和实时钓鱼。这是选型问题,不是实现问题。
上线前的自测清单
- 同一个码提交两次,第二次必须失败。
- 过期之后提交,必须失败。
- 用 A 手机号收到的码去验 B 手机号,必须失败。
- 用「注册」用途的码去调「改密码」接口,必须失败。
- 连续输错 5 次之后,即使输对也必须失败。
- 并发提交同一个正确的码 10 次,只能成功 1 次。
- 全链路日志和错误上报里搜不到明文验证码。
- 不存在的手机号和存在的手机号,返回内容与响应时间基本一致。
更系统的测试角度见 验证码测试从哪些角度找问题,对接接码平台做自动化时的重试与错误处理见 接码失败重试与错误处理 和 接码 API 对接指南。
小结
验证码的安全性几乎全部来自比较之外的那些约束。 字符串相等谁都会写,真正决定成败的是:码怎么存、绑定了什么、能试几次、多久失效、消费是不是原子的、失败时说了多少话。把这八条按顺序落到代码里,一次性验证码才真的是一次性的。概念背景见 一次性验证码(OTP)是什么。