news 2026/9/18 2:19:38

短信验证码登录实战:从架构设计到防刷风控全解析

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
短信验证码登录实战:从架构设计到防刷风控全解析

1. 为什么大家都用短信验证码登录:需求背景与整体架构

我大概是从2018年开始接手短信验证码登录相关的功能,那会儿很多App还处于“账号密码登录 + 第三方微信授权”的阶段。到了今天,短信验证码登录几乎成了所有C端产品的标配,甚至很多B端后台也把它作为第二认证方式。

先说清楚这个东西到底解决了什么问题。账号密码登录最大的痛点不是技术复杂度,而是“密码管理”。普通用户不会为你的产品单独记一个密码,他们要么用弱密码,要么一个密码走天下,要么直接忘记。短信验证码登录绕开了这一整套问题——手机号天然就是账号,验证码天然就是凭证,用户不需要记忆任何额外信息,交互成本最低。

从产品角度看,短信验证码登录还有一个隐藏价值:直接拿到用户的真实手机号。这个手机号后面能挂在一堆业务上,比如消息通知、安全提醒、二次验证、营销触达。所以很多产品宁肯放弃密码登录,也要把短信登录放在首位。

我再把一条完整的验证码登录链路拆出来,方便你建立全局观:

  1. 用户输入手机号,点击“获取验证码”
  2. 前端先做一次手机号格式校验,格式不对直接拦截
  3. 后端接收请求,检查该手机号是否在冷却期内(比如60秒内不能重复发)
  4. 后端生成6位随机数字验证码,存储到Redis,设置过期时间(通常5分钟)
  5. 后端调用短信服务商接口,把验证码发送到用户手机
  6. 用户输入收到的验证码,提交登录
  7. 后端从Redis读取验证码,比对,成功则删除,失败则处理错误计数
  8. 校验通过后,后端签发登录令牌(JWT Token或Session),返回给前端
  9. 前端保存令牌,后续所有接口请求带上它,登录闭环完成

这套流程看起来简单,但每一步展开都有细节。我拿实际做的项目来拆解,用的技术栈是Spring Boot + Redis + 阿里云短信服务,前端是Vue。你不用拘泥于这个组合,只要理解了核心逻辑,换成Go、Node.js、腾讯云、极光推送都行,思路是相通的。

1.1 验证码登录解决的核心问题

从技术视角看,验证码登录的核心不是“发短信”这件事本身,而是身份认证的降级与重建。降级是指把“用户记忆密码”这个心智负担降为“接收一次性动态口令”;重建是指每一次登录都重新建立一条新的信任链路。

实际开发中,你还会发现它天然支持一种用户增长场景——无感注册。用户第一次用手机号验证码登录时,如果数据库里没有这个手机号对应的账户,后端可以直接为他自动注册一个账户,用户不需要填昵称、设密码、传头像。这些信息可以后续在个人中心慢慢补齐。这也是为什么短信验证码登录在拉新转化率上比密码注册高出不少。

1.2 一条完整的验证码登录链路

上面那9步流程,我在项目里拆成两个核心接口:

  • POST /api/sms/send:发送验证码
  • POST /api/auth/login:校验验证码并登录

两个接口一前一后,职责完全分离。发送接口只负责“发出去”,登录接口只负责“验回来”。这样做的好处是后续如果要做语音验证码、邮箱验证码、甚至外国人手机号验证码,不需要改动登录主体逻辑,新增一个渠道类型就行。

后端存储层面,我用的是Redis。很多人会问:验证码存数据库表不行吗?也能行,但你会遇到三个麻烦:第一,验证码有天然的生命周期,过期时间要手动清理;第二,高频读写验证码字段,会给数据库带来没必要的压力;第三,验证码校验后需要立即删除,这个“一条记录只活几分钟、用完即焚”的场景,简直像是给Redis量身定做的。

1.3 技术选型:我为什么用这套组合

细说下我的选型理由。

后端用Spring Boot,主要看重生态成熟。短信服务商SDK在Java体系里最稳,遇到问题网上资料也最多。当然这不是说别的语言不行,我只是觉得团队上手成本和维护成本最低。

Redis用来存验证码,核心原因是TTL特性和原子操作。一条验证码的完整生命周期就是“写入→过期/校验后删除”,这个模型天然契合Redis。后面我会详细讲Key设计和边界情况。

短信服务商选阿里云,主要因为当时阿里云短信稳定性和到达率最优,而且国内手机号覆盖率好。这个环节的选择直接影响到短信到达延迟和费用,值得单独说一章。

前端就没什么特别的,Vue + Axios,重点在按钮倒计时、输入框自动聚焦、错误提示这些交互细节上。

2. 短信服务商怎么选:从免费额度到发送到达率的真实对比

短信服务商是验证码登录绕不开的外部依赖。它的质量直接决定了用户的登录体验——服务商接口挂了,用户收不到短信,你再怎么优化后端逻辑都没用。这一节我把自己这些年接触过的几家主流服务商情况拿出来聊聊。

2.1 主流服务商横向对比

国内主流的短信服务商主要有阿里云、腾讯云、华为云、网易云信、极光推送(短信业务)、容联云通讯等。我做了一张对比表,列的是我直接用过或深度调研过的情况:

服务商到达率口碑国内覆盖度对接复杂度价格区间(以验证码短信为例)附加能力
阿里云高,大厂背书好,三网全覆盖低,SDK完善按量计费,新用户有免费额度支持震动模板、异常检测、日发送量统计
腾讯云好,微信生态联动和阿里云基本持平支持短信签名授权、子账号权限粒度细
华为云中上略便宜企业级客户更友好
网易云信中上海外通道比较强
容联便宜语音验证码方案齐全

表格里的价格参考意义不大,因为各家活动不同,而且大客户都能拿到折扣。真正影响决策的其实是几个隐性因素。

第一是签名和模板审核速度。每个短信服务商都要求你先申请“短信签名”(比如【XX科技】)和“验证码模板”(比如:您的验证码为${code},${minutes}分钟内有效),审核通过才能发短信。阿里云的审核一般比较快,资料齐全的情况下一小时到半天。这看似不重要,但在项目上线前几天如果还没通过审核,那就非常被动了。

第二是到达率的稳定性。各家都宣称“直达率99%”,但真遇到营销短信高峰期,通道拥堵会导致到达延迟甚至丢失。我见过最离谱的情况是某小服务商在双十一晚上短信发送延迟超过20分钟,用户等得火冒三丈。大厂虽然也会延迟,但整体稳定性高出一个量级。

第三是异常告警能力。比如单日发送量突然飙升、模板被恶意刷、短信被运营商拦截,服务商有没有对应的监控和告警?有些小服务商只管“发出去”,异常全靠你自己发现。阿里云和腾讯云在这方面做得算比较全的,有控制台可看每日发送量和失败明细。

2.2 我最后的选型依据

我的建议是:验证码这类高实时性业务,优先选阿里云或腾讯云,不要为了省几厘钱去赌小服务商的通道质量。验证码登录是产品的第一道门,这个环节省下来的成本,最后大概率会在用户投诉、流失上面加倍还回去。

补充一个技巧:短信服务商可以配置“多通道冗余”。主通道挂了自动切到备选通道,这个能力在大流量场景很重要。阿里云和腾讯云都支持这种容灾配置,但需要额外花钱,一般中型项目用不到。如果你只是做一个小项目,选一家靠谱的服务商就够了,不必过度设计。

另外别忽视国际短信的问题。如果你的产品海外用户多,需要选支持国际短信的服务商,阿里云和腾讯云都有国际短信服务,但也意味着你要准备英文模板。如果只是国内App,下单前记得确认服务商送的免费额度是否包含国内通用号码。

3. 验证码该存哪里:Redis Key设计与其他方案的取舍

验证码的存储是整个登录功能里最值得花时间设计的点。很多初学者把验证码放在数据库表里,或者干脆放在后端进程的内存Map里。我先说结论:业务正式上线后,用Redis + TTL是正确姿势。下面详细讲为什么,以及Key怎么设计。

3.1 为什么首选Redis

假设你把验证码存到MySQL表里,表结构大概是:

CREATE TABLE sms_code ( id BIGINT PRIMARY KEY AUTO_INCREMENT, phone VARCHAR(20) NOT NULL, code VARCHAR(10) NOT NULL, scene VARCHAR(20) NOT NULL, expire_at DATETIME NOT NULL, used TINYINT DEFAULT 0, created_at DATETIME DEFAULT CURRENT_TIMESTAMP );

这个设计能做,但有个问题:验证码是“高写、短生命周期”数据,每条记录生成后,要么被消费掉,要么过期变成垃圾。你还需要定期清理expired_at小于当前时间的记录,否则表会越来越大。更麻烦的是,如果一天发了几十万条验证码,MySQL单表写入会成为一个瓶颈点,还要考虑索引优化、分库分表。

Redis的方案就干净得多。每条验证码设置过期时间,到期自动消失,不需要额外清理任务。单值读写O(1),高并发下性能表现好。而且Redis有SETEX这样的原子命令,做到“写入 + 过期时间”一步到位,避免中间态。

打个比方:数据库方案像是在小区公告栏贴通知,过期了还得专门派人去撕掉;Redis方案像是给每张通知贴了自动溶解的贴纸,时间到了自己消失。显然更省心。

3.2 Key设计与存储结构

Redis Key的设计我用的是这个格式:

sms:code:{scene}:{phone}

其中scene表示业务场景,比如登录用login、注册用register、找回密码用reset。为什么要加场景前缀?因为同一个手机号在同一个时间段可能同时触发了“登录”和“找回密码”,如果不加场景区分,两个流程会互相覆盖验证码,导致用户收了一个码,用在另一个地方时死活验证不过。

Value部分存的不是单纯的数字验证码。我存的是一个JSON或Hash结构,大概长这样:

{ "code": "482913", "createdAt": 1734567890, "attempts": 0, "lastSentAt": 1734567890 }
  • code:验证码
  • createdAt:生成时间
  • attempts:错误尝试次数
  • lastSentAt:最近一次发送时间

把尝试次数放在Redis里,是实现“防爆破”的重要依据:用户连续输错5次,不管验证码对不对都直接作废。后面安全章节我会再展开。

过期时间设置,我一般是5分钟。这个时间不是拍脑袋定的,它要平衡两个因素:太短会导致用户还没输入完就过期,体验差;太长会增加被爆破的风险。5分钟在绝大多数场景下是合理的。有些产品会放宽到10分钟,但我不建议,因为验证码被盗用在5分钟后概率会显著上升。

3.3 其他方案对比

如果项目还处于原型阶段,没有Redis,也可以先用进程内的ConcurrentHashMap顶一下。但有两个明显的坑:第一,重启即丢失;第二,多实例部署时,用户在A实例上发了验证码,请求打到B实例上就查不到。所以这个方案只适合本地开发联调。

还有一个方案是使用内存缓存框架如Caffeine,并且设置expireAfterWrite,偶尔用用还行。但一旦上到多实例或者需要跨服务共享验证码状态,还是得回归Redis。

顺带说一句:如果你用的是Redis集群,要注意不同实例之间的Key访问。验证码的Key要保证同一个手机号的读写都在同一个Redis节点上,否则会出现“写入后读取不到”的问题。Redis Cluster会对Key做CRC16分片,只要你用相同的Key访问,不考虑加Hash Tag,通常不会踩坑。

4. 发送验证码接口:从参数校验到频控的完整实现

接下来是最核心的部分——发送验证码和校验登录的具体实现。我先写发送接口,这个接口看似简单,真正做好需要覆盖很多边界场景。

4.1 接口协议定义

先定义接口协议:

POST /api/sms/send Content-Type: application/json { "phone": "13812345678", "scene": "login" }

参数很少,但下面这些校验一个都不能少:

  • phone必须符合大陆手机号正则,^1[3-9]\d{9}$,不合法直接返回400
  • scene不能为空,且必须在后端白名单里(防止有人传一个你根本没用过的场景号)
  • 可选参数reCaptchaToken,在风控要求高的时候做图形验证码或滑块验证

4.2 发送接口实现(含代码)

整个发送逻辑的代码流程大致是这样:

@PostMapping("/api/sms/send") public Result<Void> sendSmsCode(@RequestBody @Valid SendSmsRequest request) { String phone = request.getPhone(); String scene = request.getScene(); // 1. 手机号校验 if (!PhoneUtil.isValid(phone)) { return Result.fail("手机号格式不正确"); } // 2. 冷却时间检查:同一手机号 + 同一场景 60秒内只能发送一次 String cooldownKey = "sms:cooldown:" + scene + ":" + phone; Boolean hasCooldown = redisTemplate.hasKey(cooldownKey); if (Boolean.TRUE.equals(hasCooldown)) { return Result.fail("发送太频繁,请稍后再试"); } // 3. 生成6位随机验证码 String code = String.valueOf(ThreadLocalRandom.current().nextInt(100000, 1000000)); // 4. 写入Redis,过期时间5分钟 String codeKey = "sms:code:" + scene + ":" + phone; Map<String, Object> value = new HashMap<>(); value.put("code", code); value.put("createAt", System.currentTimeMillis()); value.put("attempts", 0); redisTemplate.opsForHash().putAll(codeKey, value); redisTemplate.expire(codeKey, 5, TimeUnit.MINUTES); // 5. 设置冷却标记 60秒 redisTemplate.opsForValue().set(cooldownKey, "1", 60, TimeUnit.SECONDS); // 6. 调用短信服务商发送 SmsRequest smsRequest = new SmsRequest(); smsRequest.setPhone(phone); smsRequest.setTemplateCode("SMS_123456"); smsRequest.setTemplateParam("{\"code\":\"" + code + "\"}"); smsResult = smsProvider.send(smsRequest); if (!smsResult.isSuccess()) { // 发送失败要回滚冷却时间,避免用户等60秒才能重试 redisTemplate.delete(cooldownKey); return Result.fail("短信发送失败,请重试"); } // 7. 记录发送日志(手机号脱敏) smsLogService.record(phone, scene, SmsStatus.SUCCESS); return Result.success(); }

这段代码里有一个需要特别强调的点:发送失败时一定要删除冷却Key。如果忽略这一步,短信服务商接口临时故障,用户点击获取验证码得到的永远都是“发送太频繁”,等60秒再点又失败,体验非常差。这个细节我在项目里真实踩过,不仅用户投诉,连运维都以为服务挂了。

生成验证码的方式用ThreadLocalRandom即可,没必要上更复杂的随机数方案。验证码本身有效期短、单次性、有频控保护,安全性足够。

4.3 发送频控:不只是同一个手机号限流

频控这个事,很多版本只做上面代码里的“60秒冷却”,其实不够。作为登录入口,短信发送是网络黑产最爱的攻击目标之一。完整的频控策略建议分三层:

第一层:单手机号维度。同一手机号60秒只能发送1次、24小时最多发送10次、7天最多发送30次。这几个阈值用Redis的INCR配合过期时间即可实现,比如每日计数Key:sms:count:daily:{date}:{phone},设置48小时过期。

第二层:单IP维度。同一IP地址每10分钟最多发送20次验证码请求。这个能有效拦截用脚本换手机号刷短信的恶意行为。用户在公司Wi-Fi下有多个同事同时登录,触发上限时稍微放宽阈值到30次比较友好。

第三层:全局维度。整个服务同一秒最大发送量做限制,防止服务商账号被流量打爆。一般服务商侧有默认限流阈值,比如单日10万条,你需要在本地提前预估一个安全值。

这三层顺序执行,任一命中就返回“操作频繁”。注意频控的返回文案要模糊处理,不要告诉攻击者到底触发了哪层限制。统一提示“操作过于频繁,请稍后再试”就够了。

5. 校验登录接口:验证码判断与令牌签发的关键细节

发送接口负责把验证码送到用户手里,登录接口负责把验证码换成一个合法的登录态。这个接口同样有很多容易出错的细节。

5.1 登录流程的校验顺序

我的校验逻辑按这个顺序执行:

  1. 手机号格式校验(和发送接口共用同一个工具类)
  2. 从Redis取验证码,取不到直接返回“验证码已过期”
  3. 比对验证码,不一致则尝试次数+1
  4. 尝试次数达到上限,删除验证码Key
  5. 比对通过后,删除验证码Key(一次性使用)
  6. 查询用户表,存在则登录,不存在则自动注册
  7. 签发JWT令牌,返回前端

这个顺序是固定的,不要反过来。比如你先把用户查出来再校验验证码,就会让攻击者通过接口响应时间差来判断一个手机号是否已经注册——这是一个很细微但真实存在的用户枚举漏洞。

5.2 核心校验代码

代码实现:

@PostMapping("/api/auth/login") public Result<LoginResponse> login(@RequestBody @Valid LoginRequest request) { String phone = request.getPhone(); String code = request.getCode(); // 1. 手机号校验 if (!PhoneUtil.isValid(phone)) { return Result.fail("手机号格式不正确"); } // 2. 获取验证码 String codeKey = "sms:code:login:" + phone; Map<Object, Object> cache = redisTemplate.opsForHash().entries(codeKey); if (cache.isEmpty()) { return Result.fail("验证码已过期,请重新获取"); } // 3. 防爆破:检查尝试次数 int attempts = Integer.parseInt(String.valueOf(cache.getOrDefault("attempts", 0))); if (attempts >= 5) { redisTemplate.delete(codeKey); return Result.fail("验证码已失效,请重新获取"); } // 4. 比对验证码 String realCode = String.valueOf(cache.get("code")); if (!realCode.equals(code)) { attempts++; redisTemplate.opsForHash().put(codeKey, "attempts", attempts); if (attempts >= 5) { redisTemplate.delete(codeKey); } return Result.fail("验证码错误"); } // 5. 验证通过,立即删除验证码 redisTemplate.delete(codeKey); // 6. 查询用户,不存在则自动注册 User user = userService.findByPhone(phone); if (user == null) { user = userService.registerByPhone(phone); } // 7. 签发JWT String token = jwtUtil.generateToken(user.getId(), user.getPhone()); return Result.success(new LoginResponse(token, user)); }

上面第3步的防爆破逻辑很关键。第4步里“错误一次计数加一次”,达到5次直接删Key。这样攻击者就不能无限爆破6位验证码了。6位数字总共100万个组合,没有次数限制的话,脚本跑几分钟就能试出来。加了5次限制后,攻击者每试5次就必须重新触发短信,成本大幅上升。

5.3 关于Token方案的取舍

登录成功后返回给前端的是一个JWT字符串。为什么不用Session?原因有三点:

  1. 无状态。验证码登录针对的是“短会话”场景,JWT自带用户信息,后端不需要单独维护Session存储,也不用处理Session同步问题
  2. 多端友好。同一个用户在App、H5、小程序同时登录,JWT可以互不影响
  3. 扩展性好。后续要做SSO单点登录、网关鉴权时,JWT的通用性更强

JWT的坑也有,最大的坑是无法主动失效。用户点“退出登录”后,只要他手里的JWT没过期,理论上还能请求后端。如果你的业务对安全性敏感,可以在Redis里维护一个"JWT黑名单",登出或异常时把jti加进黑名单。我做过简化版:生成JWT时把会话ID作为jti写入Redis,设置过期时间与JWT同步,每次鉴权时检查Redis里是否存在这个jti。这样可以做到吊销效果,代价是多一次Redis查询,但对绝大多数系统可接受。

jti的全称是JWT ID,它是JWT标准里用来唯一标识一个Token的字段,相当于给每个Token发了一张"身份证号",吊销Token时只需要把这个ID加入黑名单即可,不用遍历所有在线Token。下称"会话ID"。

6. 前端交互:点击、倒计时、错误提示这些容易被忽略的小事

后端接口做得再稳,前端交互体验拉胯,用户同样会吐槽“这个登录功能不行”。短信验证码登录的前端,我把它拆成三个重点来打磨。

6.1 获取验证码按钮的状态流转

对前端来说,获取验证码按钮有三个状态:可点击、倒计时中、不可点击。

核心的倒计时逻辑,我建议用前端本地倒计时 + 后端冷却时间双重控制。前端把倒计时设成60秒,时间到了就恢复可点击;后端也设60秒冷却,如果前端的倒计时跑偏了(比如用户改了系统时间),请求发过去会被后端拦下来,前端再做一次错误提示并重新拉回倒计时状态。

代码如下:

const COOLDOWN_SECONDS = 60; async function sendCode() { const phone = phoneInput.value; if (!/^1[3-9]\d{9}$/.test(phone)) { showError('请输入正确的手机号'); return; } if (countdown > 0) return; try { const resp = await axios.post('/api/sms/send', { phone, scene: 'login' }); if (resp.data.code === 0) { startCountdown(); } else { showError(resp.data.message); } } catch (e) { showError('网络异常,请重试'); } } function startCountdown() { countdown = COOLDOWN_SECONDS; sendCodeButton.disabled = true; sendCodeButton.textContent = countdown + 's后重发'; const timer = setInterval(() => { countdown--; sendCodeButton.textContent = countdown + 's后重发'; if (countdown <= 0) { clearInterval(timer); sendCodeButton.disabled = false; sendCodeButton.textContent = '获取验证码'; } }, 1000); }

倒计时的起点是发送成功之后,不是点击按钮之后。如果短信服务商返回失败,前端不应该进入倒计时,应该直接提示用户重试。

6.2 验证码输入框的交互细节

验证码输入框和密码输入框的交互习惯不同。我总结了几条实战经验:

第一,输入完成自动收起键盘。6位验证码输入到位后,前端自动触发登录请求,而不是让用户再点一次“登录”按钮。这个细节能明显提升转化率。实现方案是监听input事件,当value长度等于6时自动提交。

第二,粘贴支持。很多短信App可以一键复制验证码,iOS还可以自动从短信中提取验证码填充。前端需要做好输入框的监听,让粘贴的验证码也能触发自动提交。

第三,错误提示放在输入框下方,不要用弹窗。用户输错验证码后需要能看到错误原因,同时保留已经输入的手机号,只清空验证码输入框。这样用户只需要重新输入验证码,不用从头再来。

第四,自动聚焦到下一个输入框。如果是单框输入整串验证码就无所谓;如果是分6个小框,需要在每个格子输完后自动聚焦到下一个格子。这个交互看着简单,但很多前端小哥哥小姐姐第一次做都会漏掉。

6.3 App端与Web端的天生差异

在App端实现短信验证码登录时,有一个Web端没有的体验:iOS和Android系统级短信自动填充。iOS通过documentautocomplete="one-time-code"属性实现短信验证码自动填充,Android的Smart Lock也类似。原生App可以接入系统API,比如iOS的UITextFieldtextContentType = .oneTimeCode,Android的SmsRetrieverAPI。如果你的登录页在App里用WebView承载,则H5侧也会退化为手动输入。

移动端相比PC端还有一个特点:手机号键盘要设置为数字键盘,输入框inputmode="numeric"maxlength="11",避免用户还要切换键盘。

坦白讲,这些交互细节不会写在后端接口文档里,但对用户体验的改善是实打实的。我见过太多项目后端做得无可挑剔,前端却因为按钮不能倒计时、错误提示不友好、自动聚焦缺失,被测试提了一堆优化单。

7. 防刷与风控:短信接口绕不开的安全功课

短信验证码登录,本质上是用“短信通道”作为安全边界,那么短信通道本身就成了攻击目标。如果防刷做得不好,轻则被薅短信费用,重则账号体系被撞库、恶意注册。这一节我专门讲安全。

7.1 验证码爆破与“撞库”怎么防

“爆破”指的是攻击者用脚本穷举验证码,因为6位纯数字只有100万种组合,网络请求够快的话,理论上很快就能试完。针对这个,前端登录接口做了“尝试次数限制”,但只靠这个还不够,原因在于攻击者可以同时用成千上万个手机号来爆破同一个验证码。

比如攻击者提前批量获取了100万个手机号的验证码,然后写脚本对每个手机号都从000000试到999999。如果每次校验都打满5次,后端要防的就是“单用户5次”和“整体请求量”两个维度。

我建议的做法是再加一个登录接口的IP限流:同一IP每分钟最多100次登录尝试。普通用户几乎永远达不到这个阈值,但对脚本机器人是硬性限制。IP限流用Redis实现:

String ipLimitKey = "sms:limit:login:ip:" + getClientIp(); Long count = redisTemplate.opsForValue().increment(ipLimitKey); if (count == 1L) { redisTemplate.expire(ipLimitKey, 1, TimeUnit.MINUTES); } if (count > 100) { return Result.fail("操作频繁,请稍后再试"); }

另外,在同一场景下,同一个手机号如果请求登录超过10次未通过,就把它拉进一个临时黑名单,30分钟内禁止登录。这个黑名单同样用Redis实现,过期时间到自动解除。

7.2 短信轰炸怎么治

“短信轰炸”是另一个常见攻击:攻击者利用某个公开页面不需要经过图形验证码校验的后端漏洞,以任意手机号为受害者,批量触发短信验证码发送。受害者手机在短时间内会收到大量不同平台的验证码短信。

这个问题的根源往往不只是你自己的接口,而是你提供了一个“输入手机号就能收到短信”的免费入口。要防住,必须要做到几件事:

第一,发送验证码前必须经过人机校验。图形验证码、滑块验证、行为验证都行。推荐的方案是在前端集成阿里云的无痕验证或腾讯防水墙,根据后端返回的验证结果决定是否放行短信发送。人机校验会引入额外的前端SDK和后端调用,但对防轰炸至关重要。

第二,对发送频率做“全场景全局限流”。上面提到的三层频控,重点是全局维度。一旦发现某个手机号在多个scene下被频繁请求,果断拉黑。

第三,设置单日单手机号发送上限。比如24小时内最多10条。这个阈值不能太高,太高防不住轰炸,太低会误伤用户。10条是行业常用值,碰到要重新验证的场景(比如连续登录失败触发再次发送)也基本够用。

第四,关注短信服务商侧的报警。阿里云和腾讯云控制台都能看到每日发送趋势和失败详情。如果发现短信发送量突然异常上涨,优先检查是不是某个时间点涌入了大量请求,而不是先怀疑用户量涨了。

7.3 日志脱敏与审计

这一块容易被忽略,但做安全审计时经常被查。短信验证码相关的日志,无论后端日志还是数据库日志,都不允许记录完整验证码。原因很简单:日志的保存周期通常比验证码有效时间长,一旦日志泄露,攻击者可以用验证码完成登录。

我实际落地的做法是:

  • 日志只记录发送状态、手机号脱敏(138****4567)、场景、时间
  • 验证码只存在于Redis且加密存储,即使Redis被拖库也无法直接使用
  • 登录成功日志记录IP、设备、时间,便于后续做异常行为审查
  • 登录失败日志记录失败原因和IP,连续失败触发告警

有些团队会把验证码加密后再存Redis,虽然Redis本身可以设置密码和网络隔离,但多一层加密总归更安全。我用AES对称加密,密钥放在配置中心,而不是代码里或者Redis配置文件里。

8. 踩坑实录:那些只有线上环境才会暴露的问题

最后这部分我挑选了几个真实项目中遇到的坑,每一个都曾经折磨过我或我的团队。希望你能绕开这些,不用像我一样交学费。

8.1 短信发送成功但用户收不到

这个坑出现的频率,远比你想象中高。有一次线上反馈说“大批用户收不到验证码”,排查链路是这样的:

第一,看后端日志,确认短信服务商接口返回的是成功。果然,日志显示发送成功。

第二,看服务商控制台的发送详情。灵异的地方来了:控制台也显示发送成功,回执码也是“DELIVRD”——投递成功。

第三,让用户查看垃圾短信箱。反馈来了:用户在“通知类信息”里找到了短信,很多安卓手机对验证码短信做了分类,如果你的短信内容里有“验证码”“动态口令”等关键词,很可能被手机系统或安全软件拦截。

最后定位到的原因不在短信内容,而在短信模板的签名。我们当时的短信签名和App名称不一致,运营商的通道到达率受签名影响很大。签名和App名称一致、且模板规范化,能明显减少被运营商拦截的概率。

这给我们留下的教训是:“发送成功”不等于“用户收到”。做短信登录后,必须建一条监控短信到达率的告警,一旦到达率跌破一个阈值(比如95%)就要主动排查,而不是等用户来投诉。

8.2 验证码过期时间与重发逻辑的冲突

这是个很隐蔽的逻辑问题。假设我设置了验证码5分钟过期,同时冷却时间60秒。用户第一次点击获取验证码后,过了59秒发现短信没收到(其实是延迟),又点了一次,被冷却挡住。等到60秒后再次点击,后端重新生成验证码,覆盖了原来的Key。

但如果第一次的短信只是延迟了,用户在60秒后收到的是“第一个验证码”,后来他输入第一个验证码却一直提示错误,因为Redis里存的已经被第二个覆盖了。

这个问题的根因是:同场景多次发送会覆盖旧验证码。处理的办法有两种:

方案A:同一个手机号同场景的有效验证码还活着时,再次发送不覆盖,直接复用旧验证码并重置过期时间。但这样会延长验证码的有效期,安全性下降。

方案B:每次发送都会在短信文案里带上"如非本人操作请忽略",让用户确认手机收到的是最新的验证码。但用户只会觉得是产品逻辑混乱。

我采用的是折中方案:同一场景下,如果旧验证码还没过期,再次发送时沿用同一个验证码,只在用户点击“获取”后刷新过期时间。冷却期结束后允许重发,但验证码本身不变。这样做不会出现“两个验证码冲突”的问题,安全性不受影响,因为验证码仍然是一次性且短时有效的。

8.3 并发重复点击导致多条短信扣费

这个坑在新手项目里极其常见。前端虽然做了倒计时,但恶意用户或网络波动导致同一请求被重复提交时,后端如果没有幂等处理,用户点一次“获取验证码”按钮实际上发出了两三条短信。

我的处理思路是:利用Redis的SETNX命令做分布式锁。同一个手机号同场景的体验是:第一个请求拿到锁并发送短信,第二个请求进来发现锁已存在,直接返回“操作频繁”,不调用短信服务商。

String lockKey = "sms:lock:" + scene + ":" + phone; Boolean acquired = redisTemplate.opsForValue().setIfAbsent(lockKey, "1", 10, TimeUnit.SECONDS); if (Boolean.FALSE.equals(acquired)) { return Result.fail("操作频繁,请稍后再试"); } // 继续执行发送逻辑...

锁的过期时间设置为10秒,覆盖短信服务商正常响应时长就够了。如果服务商响应超过10秒,锁自动过期,用户可以再次尝试,不会卡死。

8.4 测试环境不要真发短信

开发联调时,如果每次都真的把短信发到测试同学手机号上,不仅影响测试效率,还会因为频繁发送触发频控,导致功能流程被卡住。

我们项目的做法是:通过Spring Profile区分环境。在devtest环境,不调用短信服务商,直接把验证码输出到后端日志和控制台,同时在前端页面以红色提示“测试验证码:482913”。这样开发同学随便点,不用看手机,端到端联调照样能跑通。

生产环境则增加一个“测试账号白名单”——只有白名单里的手机号才能真正收到短信。这样上生产验收时不会因为频繁触发频控而把真实的营销短信额度消耗掉。

另外我建议在配置中心加一个开关,比如sms.mock.enabled。这个开关在出现短信服务商故障时可以临时打开,让系统降级到“验证码输出到日志”的方式,先保证功能可用,等服务商恢复后再切换回去。这个预案听起来有点激进,但真的能救急。

8.5 时区与时间不一致导致的过期判断错误

最后这个坑比较冷门。当时我们的Redis服务器和应用服务器的系统时间不同步,应用生成的createAt和Redis的TTL计时出现了偏差,导致同一份验证码在应用层面判断“已过期”,但Redis里还存活着。用户看到“验证码已过期”后重新获取,又因为频控被拦住。排查了半天,最后发现是云服务器NTP同步服务没开,时间差了近两分钟。

解决方式很简单:所有服务器统一配置NTP同步,代码里判断过期时间时统一用Redis的TTL为准,不要自己用System.currentTimeMillis()createAt去算。Redis的TTL是权威时间,因为过期是Redis自己执行的,你和它纠结“时间对不对”没有意义,直接信它就对了。

写到这里,我其实还没有把短信验证码登录的所有细节都说完,比如多语言短信模板、手机号换绑时的验证码场景、分布式事务如何处理“自动注册 + 发送短信”的原子性,等等。但上面这些内容,已经足够支撑你从零到一实现一个能上生产环境的短信验证码登录功能。最后再补一句我反复踩坑后得出的体会:这一套流程真正考验人的地方,从来不是“怎么把验证码发出去”,而是把发送、存储、校验、防刷、前端体验这些环节串成一个闭环后,还能在边界条件和异常场景下保持稳定。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/18 2:17:57

Oh My Zsh lein 插件实战指南:为 Leiningen 提供 Clojure 命令补全

Oh My Zsh lein 插件实战指南&#xff1a;为 Leiningen 提供 Clojure 命令补全 【免费下载链接】ohmyzsh &#x1f643; A delightful community-driven (with 2,500 contributors) framework for managing your zsh configuration. Includes 300 optional plugins (rails, git…

作者头像 李华
网站建设 2026/9/18 2:16:21

开源项目吐槽大会:如何把社区批评转化为生产力

1. 吐槽大会的起源&#xff1a;从"这代码谁写的"到"把批评变成生产力"开源圈子里有个很有意思的现象&#xff1a;一个项目的Issue区每天都有陌生人进来抱怨&#xff0c;但维护者很少能听到"有组织的、结构化的、建设性的批评"。更多时候&#xf…

作者头像 李华
网站建设 2026/9/18 2:15:55

Cursor + IntelliJ IDEA 双端协同开发实战指南

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/18 2:14:47

文件上传超过服务器限制?从Nginx到PHP的配置排查与修复指南

1. 揭开“超过服务器限制”的真实报错与成因分析1.1 常见报错信息&#xff1a;413、500、504&#xff0c;到底哪个才是大文件问题我在接手各种项目维护时&#xff0c;最常见的用户反馈就一句话&#xff1a;“我传个文件上去&#xff0c;直接报错了。”但具体报什么错&#xff0…

作者头像 李华
网站建设 2026/9/18 2:14:41

GPU带宽成移动端发热元凶?纹理压缩与后处理优化全解析

发烫优化做了好几轮之后&#xff0c;你会慢慢发现一个规律&#xff1a;真正让手机变成暖手宝的&#xff0c;往往不是那些看起来很复杂的shader&#xff0c;也不是场景里的三角形数量&#xff0c;而是那些藏在管线深处、每天都在疯狂搬运数据的环节。纹理采样算是其中一个&#…

作者头像 李华