谁都遇到过的工具,但很少人做对
短信验证码这事看似简单——“生成 6 位数字、发短信、校验”,但只要你接过线上系统,就一定遇到过这些事故:
- 凌晨突然被短信轰炸刷了几万条短信,扣费上万
- 某个手机号被竞争对手循环请求,正常用户收不到
- 攻击者拿到泄漏的手机号字典,爆破登录接口
- 客服收到投诉:“我点了 5 次还没收到验证码”——其实是发出去了,被运营商拦了
验证码的设计要同时平衡安全、成本、用户体验三个维度,任何一个出问题都会被现实毒打。本文把一个生产级验证码系统应该考虑的事情过一遍。
一、需求拆解
一个验证码功能,至少要回答这些问题:
| 问题 | 决策点 |
|---|---|
| 验证码长度? | 6 位数字(短信) / 6 位字母数字(邮箱) |
| 有效期? | 5 分钟(业内默认) |
| 同一手机号 1 分钟内能再请求吗? | 否 |
| 同一手机号 1 天最多发几次? | 5-10 次 |
| 同一 IP 1 小时最多发几次? | 20 次(防扫号) |
| 校验失败几次后锁定? | 5 次 |
| 用过/锁定后能复用吗? | 否,立即作废 |
| 短信和邮箱分开计数吗? | 通常分开 |
这些数字没有标准答案,根据业务的安全 vs 体验权衡来定。后面所有实现都基于这些规则。
二、整体架构
核心组件:
- 验证码生成:随机数字串 + 时间戳
- 存储:Redis(带 TTL)
- 限流:Redis 计数器 + Lua 脚本
- 发送:解耦短信通道
- 校验:原子读取 + 删除
三、生成与存储
1. 生成
短信验证码不要用 Math.random(),用 SecureRandom:
| |
Math.random() 是伪随机,对一般业务够用,但对验证码这种安全相关的事,能用 SecureRandom 就用 SecureRandom。
2. 存储
Redis 是最自然的选择——key 带场景标识,value 存"验证码 + 校验失败计数",TTL 5 分钟:
| |
不要用纯字符串 code——校验失败次数、剩余次数都要记下来。
3. 关键:场景隔离
同一手机号在不同业务场景下的验证码必须分开存:
| |
如果不分场景,攻击者从注册接口拿到验证码,能用来登录别的场景——这是真实发生过的越权事故。
四、多维限流:核心防刷
只做"1 分钟限发一次"是远远不够的。生产系统至少要做四层限流:
1. 同手机号 + 同场景:60 秒间隔
最朴素的——前端按钮置灰是一道,后端必须自己再校验一次:
| |
2. 同手机号每天总量上限
24 小时内最多 10 条,超过拒绝:
| |
3. 同 IP 限流
防扫号的关键——攻击者写脚本遍历手机号字典:
| |
4. 全局熔断:每分钟总短信量
防 DDoS 时的最后一道——配置中心维护一个全局阈值:
| |
短信成本可不便宜——这层不开,攻击场景下损失会被迅速放大。
五、发送:人机校验前置
仅靠 IP 限流防不住僵尸网络(每个肉鸡 IP 不同)。真正的最强防御是滑块/拼图/reCAPTCHA——人机校验通过后才允许调发送接口。
后端校验 Token 时要有"一次性"语义——同一个 Token 不能复用。
很多业务做"分层防御":
- 第 1 次发送:免人机校验
- 1 小时内第 2 次:要滑块
- 第 3 次:要图形+滑块
- 触发风控:直接拒绝
体验和安全的折中点。
六、校验:原子操作 + 失败次数
校验逻辑看起来简单,但有几个容易写错的细节:
| |
四个最常见的坑
- 校验成功后忘了删除 key——同一个验证码能复用,等于没校验
- 校验失败时直接覆写 key——重置了 TTL,攻击者可以"持续小流量爆破"
- 校验失败计数与原 record 分开存储——产生不一致,要么放一起要么用 Lua 原子更新
getExpire边界值没兜底——Redis 在 key 不存在时返回 -2、无 TTL 时返回 -1,照搬当 TTL 用会出问题(上面代码已加兜底)
更稳妥的版本是用 Lua 脚本一次性完成"读取 + 比对 + 计数 + 保留 TTL"——彻底避免上面这些时序坑:
| |
七、典型事故回顾
事故 1:短信轰炸
某创业公司 0 限流上线,凌晨被脚本攻击——一晚发出 8 万条短信,短信通道账户余额清零,第二天用户登录时发不出短信。
教训:任何对外发短信的接口必须先有限流,哪怕是日活 10 的小项目。
事故 2:验证码被复用
注册接口的验证码校验后没删 key,研发以为"反正 5 分钟会过期"。攻击者发现后5 分钟内反复用同一验证码注册不同账号,刷出几千个假账号。
教训:验证码是一次性的,校验后立刻 DEL。
事故 3:跨场景复用
登录场景和找回密码用同一个 Redis key。攻击者通过"找回密码"功能拿到验证码,直接拿来登录目标账号——因为后端没区分。
教训:场景必须隔离,key 里带 scene。
事故 4:被竞争对手刷光额度
同一个手机号每分钟能发一次,攻击者每分钟刷一次,正常用户的请求被冷却时间挡住。
教训:单手机号也要有日发送上限,不只是冷却时间。
八、邮箱验证码的差异
邮箱验证码和短信思路类似,但有几个关键差别:
| 维度 | 短信 | 邮箱 |
|---|---|---|
| 成本 | 高(每条几分钱) | 极低 |
| 触达速度 | 秒级 | 分钟级(垃圾邮件、延迟) |
| 可靠性 | 高(运营商) | 中(垃圾箱、屏蔽) |
| 长度建议 | 6 位数字 | 6 位字母数字混合 |
| 防刷优先级 | 高(成本敏感) | 中 |
| 用户体验 | 通常好 | 经常被丢进垃圾箱 |
邮箱可以用更长的验证码(甚至链接式),因为复制粘贴方便;短信必须短,因为人手输入。
邮箱链接式校验(点击链接验证)的好处是:可以包含完整 token,避免用户手输错。但要注意 token 一次性。
九、其他增强项
1. 验证码不要在响应里返回
测试代码里偶尔为了方便会返回 code 给前端——上线前一定要删干净。生产环境的验证码只能从用户终端获取。
2. 日志脱敏
| |
完整手机号留在日志里,运维拉去做大数据分析时容易出"内部数据泄漏"事故。
3. 短信通道熔断
阿里云、腾讯云的短信网关偶尔抽风。一个通道连续失败 N 次后自动切换到备用通道:
| |
4. 监控告警
至少要有:
- 验证码发送 QPS 监控
- 失败率监控(>5% 报警)
- 校验失败率监控(>30% 报警,可能在被爆破)
- 单 IP 高频请求告警
十、一份生产级 Checklist
发布前对照这份清单逐条 check:
- 验证码用
SecureRandom生成 - Redis key 带 scene,绝不跨场景复用
- TTL 5 分钟(不要更长)
- 同手机号 60 秒间隔
- 同手机号每天 ≤ 10 条
- 同 IP 每小时 ≤ 20 次
- 全局每分钟总量阈值
- 频繁请求触发滑块校验
- 校验成功立刻 DEL
- 校验失败次数 ≥ 5 直接锁定
- 校验失败时 TTL 不被重置
- 接口响应里不返回 code
- 日志里手机号脱敏
- 短信通道有 fallback
- QPS 和失败率都有监控告警
少做任何一条都可能在某一天变成事故。
小结
把全文压一句:
验证码的难度从来不在『生成 6 位数字』,而在『当系统被恶意调用时,仍然能保护用户、保护成本、不影响正常体验』。
记住三个原则:
- 任何对外发短信的接口必须有限流——这是底线
- 验证码是一次性的,校验后立刻删
- 场景必须隔离,不要跨业务复用 key
把这三条做对,再叠加多维限流和人机校验,你的验证码系统就基本不会被薅羊毛了。