场景设计:手机短信验证码的生成、发送与验证
1. 题目本质
短信验证码看起来只有三步:
1 | |
但真正上线时会遇到:
- 验证码被暴力猜解;
- 短信接口被刷,产生巨额费用;
- 同一手机号频繁发送;
- 攻击者批量轰炸他人手机号;
- 旧验证码和新验证码同时有效;
- 短信供应商超时、重复发送或回执延迟;
- 用户重复点击导致多条短信顺序混乱;
- 验证接口重试导致验证码被重复消费;
- 验证码明文出现在数据库、日志和监控中;
- SIM 换卡、短信劫持和社会工程攻击;
- 注册、登录、找回密码等场景安全等级不同。
因此,短信验证码系统不是一个随机数工具类,而是一个带状态、限流、风控、审计和供应商治理的认证挑战系统。
2. 先明确短信验证码的定位
短信 OTP 能证明的是:
1 | |
它不能天然证明:
- 这个手机号一直属于此人;
- SIM 卡没有被换卡攻击;
- 手机没有中木马;
- 短信没有被转发;
- 运营商链路绝对安全;
- 用户没有把验证码告诉诈骗者。
NIST SP 800-63B 将基于公共电话网络的带外认证视为受限认证方式。工程上应把短信验证码用于风险可接受的场景,并对高价值转账、密钥修改、管理员登录等操作叠加更强认证,例如 Passkey、TOTP、硬件密钥或设备绑定确认。
3. 场景必须隔离
验证码不能只有“手机号 + code”两个字段。至少要绑定业务场景:
1 | |
同一个手机号在注册场景收到的验证码,不能用于重置密码。
推荐挑战主键:
1 | |
而不是只用:
1 | |
因为同一个手机号可能同时存在不同场景、不同设备发起的挑战。
4. 总体架构
flowchart LR
APP[客户端] --> GW[API Gateway / WAF]
GW --> SMS[验证码服务]
SMS --> RISK[风控与反滥用]
SMS --> LIMIT[限流服务]
SMS --> STORE[(挑战状态 Redis)]
SMS --> OUTBOX[(发送任务/Outbox)]
OUTBOX --> MQ[消息队列]
MQ --> SENDER[短信发送器]
SENDER --> P1[供应商 A]
SENDER --> P2[供应商 B]
P1 --> CALLBACK[状态回执]
P2 --> CALLBACK
CALLBACK --> SMS
SMS --> AUDIT[(安全审计/发送流水)]
SMS --> METRIC[监控与成本分析]
将“创建验证码挑战”和“调用短信供应商”适度解耦,可以:
- 控制供应商并发;
- 做重试和切换;
- 统一模板和签名;
- 记录成本;
- 隔离供应商故障。
但登录等场景通常希望尽快反馈“发送请求已受理”。异步不是让用户等半天,而是避免把供应商的不稳定事务耦合到核心接口中。
5. 核心状态机
stateDiagram-v2
[*] --> CREATED: 创建挑战
CREATED --> QUEUED: 进入发送队列
QUEUED --> SENT: 供应商受理
QUEUED --> SEND_FAILED: 发送失败
SENT --> VERIFIED: 验证成功并消费
SENT --> LOCKED: 错误次数超限
SENT --> EXPIRED: 超过有效期
SEND_FAILED --> QUEUED: 满足条件时重试/切换供应商
VERIFIED --> [*]
LOCKED --> [*]
EXPIRED --> [*]
关键不变量:
1 | |
6. 验证码如何生成
6.1 使用密码学安全随机数
Java 中应使用 SecureRandom,不要使用:
Math.random();- 普通
Random; - 当前时间戳后 6 位;
- 手机号后 6 位;
- 自增序列;
- 可预测哈希截断。
示例:
1 | |
000042 是合法 6 位验证码,因此必须使用字符串保存,不能用整数导致前导零丢失。
6.2 位数选择
常见为 6 位数字:
1 | |
安全性不只取决于位数,还取决于:
- 有效期;
- 最大尝试次数;
- 限流;
- 场景绑定;
- 设备和会话绑定;
- 风控;
- 是否一次性消费。
一个无限尝试的 8 位验证码,也可能比只允许 5 次尝试的 6 位验证码更危险。
6.3 不要为了避免重复降低随机性
“最近 10 次不能重复”通常不是核心要求。随机数偶尔重复并不等于不安全,关键是挑战 ID、场景、有效期和一次性消费。
7. 验证码是否明文保存
推荐不要在 Redis、数据库或日志中长期明文保存验证码。
7.1 HMAC 摘要
验证码熵较低,单纯无盐 SHA-256 容易被枚举。可以使用服务端密钥做 HMAC:
1 | |
验证时对用户输入做相同计算并使用常量时间比较。
示例:
1 | |
服务端密钥应由密钥管理系统管理和轮换,不能硬编码在仓库。
7.2 为什么短信供应商仍需要明文
供应商必须拿到验证码文本才能发送。因此明文验证码会短暂存在于:
- 创建请求进程内存;
- 发送任务的受保护载荷;
- 供应商调用链。
要做到:
- 不写普通日志;
- 不进入 APM 请求体采集;
- MQ 消息加密或受严格 ACL 保护;
- 发送完成后不保留明文;
- 供应商后台权限最小化;
- 对模板参数做脱敏。
8. Challenge 数据结构
1 | |
Hash 示例:
1 | |
客户端获得 challengeId,验证时同时提交:
1 | |
手机号可由挑战中查出,也可再次提交后进行一致性校验。
9. 发送流程
sequenceDiagram
autonumber
participant App as 客户端
participant API as 验证码服务
participant Risk as 风控/限流
participant Redis as Challenge Store
participant MQ as 发送队列
participant Sender as 短信发送器
participant Vendor as 短信供应商
App->>API: 请求发送(phone, scene, device, requestId)
API->>API: 规范化手机号、校验场景
API->>Risk: IP/手机号/设备/账号风险检查
Risk-->>API: ALLOW / CHALLENGE / REJECT
API->>Redis: 原子检查冷却时间并创建 challenge
API->>MQ: SendSms(challengeId, encryptedCode, template)
API-->>App: challengeId + resendAfter + expireAt
MQ->>Sender: 发送任务
Sender->>Vendor: 模板短信
Vendor-->>Sender: request accepted/vendorMessageId
Sender->>Redis: 更新 SENT/失败状态
9.1 客户端返回“受理”还是“发送成功”
供应商接口返回通常只代表受理,不代表短信已经送达手机。
API 可返回:
1 | |
而不是绝对承诺:
1 | |
运营商回执可能延迟甚至缺失。用户体验上可以显示“验证码已发送,请注意查收”,但系统内部状态要区分供应商受理和最终送达回执。
10. 验证流程
sequenceDiagram
autonumber
participant App as 客户端
participant API as 验证码服务
participant Redis as Challenge Store
participant Biz as 登录/注册业务
participant Audit as 审计
App->>API: verify(challengeId, code, scene, requestId)
API->>Redis: Lua 原子校验状态/过期/次数/摘要
alt 验证码错误
Redis-->>API: INVALID + remainingAttempts
API->>Audit: 记录失败与风险信号
API-->>App: 验证失败
else 已锁定或过期
Redis-->>API: LOCKED/EXPIRED
API-->>App: 失败
else 验证成功
Redis-->>API: VERIFIED + verificationTicket
API->>Biz: 使用一次性票据继续登录/注册
API->>Audit: 记录成功
API-->>App: verificationTicket
end
11. 为什么验证必须原子完成
错误流程:
1 | |
两个并发请求可能都在状态更新前通过,导致同一个验证码被消费两次。
正确做法是用 Lua 或数据库条件更新一次完成:
- Key 是否存在;
- 状态是否为 SENT;
- 是否过期;
- 场景是否一致;
- 尝试次数是否超限;
- 摘要是否匹配;
- 错误则原子增加次数;
- 正确则原子改为 VERIFIED;
- 生成或写入一次性验证票据。
伪 Lua:
1 | |
注意:Lua 中直接比较摘要字符串并不等价于应用层常量时间比较。若对侧信道风险要求高,可将关键比较放在受控应用逻辑中,再用 CAS 脚本消费;对于远程网络攻击,限流、尝试次数和短 TTL 通常更关键。
12. 验证成功后不要直接永久信任 challengeId
推荐生成短期、一次性的 verification_ticket:
1 | |
后续登录/注册接口消费该票据:
flowchart LR
V[验证码校验成功] --> T[签发短期 verification ticket]
T --> B[登录/注册/改手机号业务]
B --> C{原子消费 ticket}
C -- 成功 --> S[完成业务]
C -- 已消费/过期 --> F[拒绝]
这样可避免:
- 登录业务反复重新校验验证码;
- 验证码服务和账号业务强耦合;
challengeId被无限复用;- 不同场景串用。
票据必须绑定:
- 手机号;
- 场景;
- 账号或用户(若已知);
- 设备/会话(视风险);
- 有效期;
- 一次性消费状态。
13. 发送限流
仅限制“60 秒内不能重发”远远不够。应多维限流。
13.1 手机号维度
1 | |
13.2 IP 维度
1 | |
但大量用户可能共享企业 NAT 或运营商出口,IP 限流不能过于简单粗暴。
13.3 设备维度
1 | |
13.4 账号维度
已登录场景可限制同账号修改手机号、找回密码等敏感操作。
13.5 号码段和国家维度
防止攻击者批量枚举某号段或高成本国际短信目的地。
13.6 全局成本熔断
监控每分钟、每小时短信量和费用。当异常增长时:
- 限流收紧;
- 开启 CAPTCHA;
- 暂停高风险国家/号段;
- 切换供应商;
- 人工告警。
14. Redis 滑动窗口限流
可以使用 ZSet:
1 | |
Lua 原子流程:
- 删除窗口外成员;
- 统计当前数量;
- 超限则拒绝;
- 未超限则写入本次 requestId;
- 设置 TTL。
伪代码:
1 | |
必须放进 Lua,避免并发请求都先看到未超限,然后同时写入。
对于固定时间窗口,也可用 INCR + EXPIRE,实现简单但边界处可能突发双倍流量。选择取决于精度和成本。
15. 防短信轰炸
攻击者可能不断给受害者发送验证码,造成骚扰。
措施:
- 同手机号冷却时间;
- 多维限流;
- 高风险请求先通过 CAPTCHA;
- 风险设备和代理 IP 拦截;
- 同一手机号频繁被多个 IP 请求时提高风险;
- 短信文案明确“请勿泄露,非本人操作请忽略”;
- 提供投诉和安全冻结渠道;
- 对攻击流量返回统一响应,避免号码枚举;
- 防止第三方页面跨站触发发送接口。
发送接口应校验 CSRF/Origin(Web 场景)、设备证明和请求签名,不能只是公开的手机号 POST 接口。
16. 防号码枚举
注册或找回密码时,如果响应:
1 | |
攻击者可以枚举用户。
可根据业务采用统一响应:
1 | |
但完全统一响应会影响用户体验。可以在前端流程、风控和错误延迟之间做平衡,至少避免接口对未认证攻击者返回过于明确的账号存在性信息。
17. 新验证码是否让旧验证码失效
建议同一“手机号 + 场景 + 业务会话”只有一个当前有效 challenge 版本。
当用户点击重发:
1 | |
否则用户可能收到:
1 | |
由于运营商延迟,B 先到、A 后到。若两个都有效,安全和体验都变差。
客户端页面显示最新挑战的 challengeId,只验证最新版本。
但要注意:重发不能无限立即发生,仍需冷却和总次数限制。
18. 短信供应商治理
18.1 多供应商
可以配置主备供应商:
flowchart TD
T[发送任务] --> R[路由策略]
R --> A[供应商 A]
R --> B[供应商 B]
A -->|超时/熔断| B
A --> M[成功率/延迟/成本]
B --> M
路由考虑:
- 国家和运营商覆盖;
- 送达率;
- 延迟;
- 单价;
- 模板审核;
- 当前熔断状态;
- 每日配额;
- 合规和数据驻留。
18.2 供应商超时能否直接重试
危险点:供应商可能已经受理,但响应在网络中丢失。立即切换另一个供应商会导致用户收到两条短信。
需要:
- 供应商侧幂等请求 ID;
- 查询发送状态;
- 延迟确认后再切换;
- 对重复短信可接受度做业务权衡;
- 记录 providerRequestId 和 vendorMessageId。
18.3 回执不等于绝对事实
运营商回执可能延迟、不完整或状态语义不同。内部统一映射:
1 | |
19. 发送流水
1 | |
不要在流水中保存明文验证码和完整手机号。手机号可保存加密值供受控业务使用,同时保留不可逆 hash 用于统计和限流关联。
20. API 设计
20.1 请求发送
1 | |
1 | |
响应:
1 | |
20.2 验证验证码
1 | |
1 | |
响应:
1 | |
错误响应不必告诉攻击者验证码具体错在哪个字符,也不要暴露过多限流规则。
21. 幂等设计
21.1 发送幂等
同一个 Idempotency-Key 重试,应返回相同 challenge,而不是再发一条短信。
21.2 验证幂等
同一验证请求响应丢失后重试:
- 可以返回原
verificationTicket的安全恢复结果; - 或返回“已验证”,由同一业务会话继续;
- 不应再次增加失败次数;
- 不应重复消费到多个业务。
21.3 业务消费幂等
verificationTicket 在登录/注册业务中原子消费。即使业务请求重复,也只产生一个账号绑定或一次敏感操作。
22. 失败次数和锁定
建议:
1 | |
达到上限后:
- 状态变为 LOCKED;
- 该 challenge 永久失效;
- 是否允许立即重新发送由风险策略决定;
- 对连续失败的手机号/IP/设备提高风险;
- 不要仅清空 attemptCount 让攻击者无限循环。
同时设置全局验证限流:
1 | |
否则攻击者可以不断创建新 challenge,每个都尝试 5 次。
23. 有效期设计
常见为几分钟,但应按场景配置:
- 登录:较短;
- 高延迟国际短信:可适度更长;
- 支付确认:更短并绑定交易;
- 找回密码:短时有效,后续票据也要短时。
验证码过期判断必须使用服务端时间。
Redis TTL 到期后 Key 消失,但为了审计可在持久化流水中保留:
1 | |
24. 验证码日志脱敏
以下内容不能进入普通日志:
1 | |
可记录:
1 | |
要检查:
- 网关访问日志;
- APM body capture;
- 异常堆栈;
- MQ 控制台;
- 数据库慢日志;
- 第三方监控平台;
- 客服后台。
很多验证码泄漏不是算法问题,而是可观测平台把请求体完整收集了。
25. 容量和成本估算
假设:
- 每天发送 1000 万条短信;
- 峰值集中在活动或登录高峰;
- 每条短信成本由国家、供应商和通道决定;
必须监控:
1 | |
漏斗:
flowchart LR
R[发送请求] --> A[风控允许]
A --> S[供应商受理]
S --> D[送达/未知]
D --> V[验证成功]
如果发送量增长但验证成功量不变,可能是:
- 攻击;
- 供应商送达率下降;
- 模板问题;
- 客户端重复请求;
- 验证码延迟导致用户重发;
- 产品流程异常。
26. 故障场景
26.1 Challenge 创建成功,发送任务失败
状态标为 SEND_FAILED,可按策略重试或切换供应商。客户端可允许在冷却后重新请求。
26.2 供应商受理成功,服务响应丢失
使用 providerRequestId 查询,不立即盲目切换,避免重复短信。
26.3 短信延迟到达
旧 challenge 被新版本 supersede 后,即使旧短信后到也不能验证成功。
26.4 Redis 主从切换丢失 challenge
验证码验证失败,提示重新发送。不能降级为“只要格式正确就通过”。
对高价值认证,可把挑战状态写入更强持久化存储或使用具备强一致能力的状态服务。
26.5 验证成功,业务登录失败
verificationTicket 仍在短时间内有效,业务可幂等重试;或业务消费失败后不标记消费。需要明确票据消费与业务事务边界。
26.6 业务成功,响应丢失
业务接口自身通过幂等键返回原结果,不能让用户重新走短信流程并重复创建账号或绑定。
27. 高风险场景的加强措施
对于改手机号、找回账号、资金操作:
- 校验现有登录态;
- 旧设备确认;
- Passkey/WebAuthn;
- TOTP 或硬件密钥;
- 风险等待期;
- 新旧手机号双向验证;
- 最近换卡风险检测;
- 新设备和异地风控;
- 生物识别;
- 人工申诉流程;
- 操作后通知所有已登录设备。
不要把短信 OTP 当作所有敏感操作的唯一防线。
28. 常见错误回答
错误一:Random 生成 6 位数字
普通伪随机数可预测,应使用 SecureRandom。
错误二:Redis Key 只用手机号
不同场景会互相覆盖,也无法区分多次挑战和设备会话。
错误三:明文验证码写数据库和日志
内部人员、日志平台和备份都会扩大泄漏面。
错误四:验证时 GET 后 DELETE
并发请求可能都验证成功,应原子校验并消费。
错误五:只限制 60 秒重发
攻击者仍可对大量手机号、IP 或设备进行批量攻击,需要多维限流和全局成本熔断。
错误六:供应商超时就立刻换另一家
第一家可能已发送,会造成重复短信和费用翻倍。
错误七:验证码正确后永久标记手机号已验证
验证结果必须绑定场景、业务会话和短有效期,并通过一次性票据消费。
错误八:短信验证码可以保护所有高价值操作
短信存在 SIM 换卡、劫持和社会工程风险,高风险操作需要更强认证。
29. 面试总结话术
我会把短信验证码建模为短生命周期 challenge,而不是简单的 phone-code Key。服务端使用 SecureRandom 生成验证码,使用 HMAC 摘要保存,并绑定 challengeId、手机号、场景、设备和有效期。发送前按手机号、IP、设备、账号、号段和全局成本做多维限流与风控,通过消息队列和供应商适配层发送,记录受理和回执状态。验证时使用 Redis Lua 或数据库条件更新原子完成状态、过期、错误次数、摘要匹配和一次性消费;成功后签发短期 verification ticket,后续业务再原子消费。重发会让旧 challenge 失效,接口和供应商调用都要幂等。短信 OTP 只适用于风险可接受的场景,高价值操作应叠加 Passkey、TOTP 或设备确认。
30. 延伸追问
- 为什么验证码摘要不能简单使用无盐 SHA-256?
- Redis 挂了以后是否允许查数据库验证?
- 同一个手机号 60 秒内重复点击如何幂等?
- 供应商超时但实际已经发送,如何避免双发?
- 如何防止攻击者批量轰炸手机号?
- 修改手机号为什么需要验证旧手机号和新手机号?
- 验证成功后业务接口失败,票据如何恢复?
- 如何防止验证码出现在 APM 和网关日志?
- 国际短信成本突然上涨如何自动熔断?
- 短信 OTP 与 Passkey 如何组合?
参考资料
- NIST SP 800-63B: https://pages.nist.gov/800-63-4/sp800-63b.html
- NIST Authenticator Guidance: https://pages.nist.gov/800-63-4/sp800-63b/authenticators/
- OWASP Authentication Cheat Sheet: https://cheatsheetseries.owasp.org/cheatsheets/Authentication_Cheat_Sheet.html
- OWASP Credential Stuffing Prevention Cheat Sheet: https://cheatsheetseries.owasp.org/cheatsheets/Credential_Stuffing_Prevention_Cheat_Sheet.html
- Redis SET: https://redis.io/docs/latest/commands/set/
- Redis EXPIRE: https://redis.io/docs/latest/commands/expire/