短信验证码接口的九个坑与防刷清单

站内登录、注册、找回密码、绑定手机,全都绕不开短信验证码。看起来只是调一个 HTTP 接口,实际上从「用户点了发送」到「验证码被正确校验」之间,有十几个地方能出错,而且每一处的失败形态都不一样:用户说收不到、接口说发送成功、日志里只有一行 200。这篇把自己踩过的坑按顺序理一遍,从接口选型到上线监控,尽量给出能直接照抄的代码和判据。

一、选通道:别只看单价

短信服务商很多,个人站长最容易被单价的差异吸引,比如一家 0.035 元一条,另一家 0.045 元。按每月一万条算,差价一百块,看起来不少,但一旦出事,损失远远不止这一百块。

选通道时真正该看的四件事:

到达率与延迟。正规通道的三网到达率都在 95% 以上,差距主要体现在延迟的稳定性上。你可以自己测:同一时间向移动、联通、电信各发一条,记录实际收到的时间差。如果某家通道在高峰期延迟超过 10 秒,登录流程就会卡住,用户会以为没发出去然后反复点击。

是否支持测试签名与模板。验证码短信必须用「模板」报备,模板审核通常一到三个工作日,签名需要企业资质。个人站长如果没有营业执照,只能找支持个人实名的服务商,这一点要在试用之前问清楚,否则接口写完了发现开不了号。

错误码的清晰程度。好的通道会明确区分「手机号格式错误」「黑名单」「余额不足」「模板不匹配」「频率超限」,差的通道一律返回「发送失败」。前者你能针对性处理,后者只能全量记日志。

有没有发送明细查询。后台能查每条短信的下发状态回执(DELIVRD / UNDELIV),这是排查「用户说没收到」的唯一手段。没有回执查询的通道直接排除。

二、验证码本身:六位随机数就够,但必须用可靠随机源

验证码不需要长,六位数字足够——十分钟有效期、最多错五次、单号一天限十次,爆破空间已经被压到可以忽略。真正容易出错的是随机数的产生方式。

<?php
// 错误:rand() / mt_rand() 可预测,且在部分老版本 PHP 上种子固定
// $code = sprintf('%06d', mt_rand(0, 999999));

// 正确:random_int 是密码学安全的
$code = sprintf('%06d', random_int(0, 999999));

PHP 7 以后一律用 random_int。如果版本更老(比如还跑在 5.6 上),至少用 openssl_random_pseudo_bytes 转换:

$n = hexdec(bin2hex(openssl_random_pseudo_bytes(4))) % 1000000;
$code = sprintf('%06d', $n);

还有一个细节:不要用「递增编号 + 哈希」或者「时间戳取后六位」这类看似随机实际上有规律的方案。攻击者只要注册两个账号拿到两个验证码,就能反推出规律。验证码的价值在于不可预知,不在长度。

三、存储:Redis 加三重键

验证码不要存数据库。数据库写入会产生大量无用行,清理脚本一旦没跑好,表会膨胀到几百万行。用 Redis 存,键设计要能同时支持「校验」「限频」「防重放」三件事:

code:{scene}:{mobile}  -> 验证码        TTL 600
cd:{scene}:{mobile}    -> 60 秒冷却    TTL 60
try:{scene}:{mobile}   -> 错误次数     TTL 600
day:{scene}:{mobile}   -> 当日发送次数 TTL 86400
ip:{ip}                -> 当日发送次数 TTL 86400

四个键各有用途,缺一个就会出现某类事故。少了冷却键,用户连点十次就发十条,钱直接烧掉;少了错误次数键,六位验证码可以被无限次尝试;少了日限额,同一个号被脚本刷一整天。IP 维度是最后一层,因为手机号可以伪造格式、可以拿号池轮换,但 IP 池要贵得多。

发码逻辑的顺序很重要,先校验再写入,不要先写入再校验:

<?php
function sendCode($scene, $mobile, $ip) {
    if (!preg_match('/^1[3-9]\d{9}$/', $mobile)) {
        return ['ok' => false, 'msg' => '手机号格式不正确'];
    }
    $r = new Redis();
    $r->connect('127.0.0.1', 6379);

    if ($r->exists("cd:$scene:$mobile")) {
        return ['ok' => false, 'msg' => '请求过于频繁,请稍后再试'];
    }
    $dayKey = "day:$scene:$mobile";
    if ($r->get($dayKey) >= 10) {
        return ['ok' => false, 'msg' => '今日发送次数已达上限'];
    }
    $ipKey = "ip:$ip";
    if ($r->get($ipKey) >= 50) {
        return ['ok' => false, 'msg' => '今日发送次数已达上限'];
    }

    $code = sprintf('%06d', random_int(0, 999999));
    // 先写验证码,再调接口;接口失败则删掉
    $r->setex("code:$scene:$mobile", 600, $code);
    $ok = callSmsApi($mobile, $code, $scene);
    if (!$ok) {
        $r->del("code:$scene:$mobile");
        return ['ok' => false, 'msg' => '发送失败,请稍后重试'];
    }
    $r->setex("cd:$scene:$mobile", 60, 1);
    $r->incr($dayKey);  $r->expire($dayKey, 86400);
    $r->incr($ipKey);   $r->expire($ipKey, 86400);
    return ['ok' => true, 'msg' => '已发送'];
}

注意 incr 之后一定要重新 expire,因为 incr 一个不存在的键会创建它但不带过期时间,第二次调用时 expire 又会把 TTL 重置成 86400,实际效果是「一直不清理」。正确写法是先判断键是否存在再决定要不要设过期,或者直接 setex 一个合并的结构。最省事的是用 Lua 脚本保证原子性:

local n = redis.call('INCR', KEYS[1])
if n == 1 then redis.call('EXPIRE', KEYS[1], 86400) end
return n

上面的 PHP 示例里 incr 和 expire 分开写,在实际高并发下会有竞态;个人站点量级不大时影响有限,但既然知道有正确写法,就别留这个隐患。

四、校验:三件事必须同时做

校验环节的事故最多,因为很多人只做了「比对验证码」这一步。

第一件,验证成功后立刻删除。不删的话,同一个验证码在十分钟内可以重复使用,只要它被日志、被截图、被中间人看过一次,就等于永久有效。用 del 而不是设过期:

$stored = $r->get($key);
if ($stored === false) { return ['ok' => false, 'msg' => '验证码已过期']; }
if ($stored !== $input) {
    $n = $r->incr($tryKey);
    if ($n == 1) { $r->expire($tryKey, 600); }
    if ($n >= 5) { $r->del($key); return ['ok' => false, 'msg' => '验证码已失效,请重新获取']; }
    return ['ok' => false, 'msg' => '验证码不正确'];
}
$r->del($key);
$r->del($tryKey);

第二件,比对必须用恒等比较,不能用 ==。PHP 的松散比较在两边都是纯数字字符串时会先转成整数,但这本身不构成漏洞(六位数字转整数后依然相等);真正的坑是 "0e12345" == "0e67890" 会返回 true。验证码是六位定长数字,前导零的情况存在(比如 012345),所以用 === 或者 hash_equals 才安全。

第三件,验证码要和场景绑定。注册场景发的码不能用来重置密码,否则攻击者只要控制一个页面就能用注册流程拿到的码去改别人密码。键里带 $scene 就是干这个的,别为了省事共用一个键。

五、防刷:真正的战场在发送之前

前面那四个键是最基础的。做过一段时间站点之后你会发现,光靠次数限制挡不住专业刷子,他们会用号池、用代理池、把发送间隔卡在刚好不触发冷却的 61 秒。这时候要加的手段是这几层:

图形验证或滑块前置。发码接口在调短信服务商之前,先要求一个一次性 token,这个 token 由用户完成滑块或点选后由后端签发,有效期 60 秒、单次有效。成本最低的做法是自己画一个简单的算术题(后端存答案),虽然不如滑块强度高,但足以挡住 99% 的脚本。

同号段与同 IP 段的聚合限制。不只限制单个 IP,还要限制 /24 网段的总量。代理池常常集中在少数几个大段里,按段限流能一次挡住一大片。

手机号归属地与行为一致性。如果注册流程里用户填了所在地,而手机号归属地与之完全不匹配,配合新注册、无历史行为,可以要求二次验证。这一层要谨慎,不要误伤出差、异地登录的正常用户。

失败率的异常检测。正常的发码接口失败率(含冷却拒绝)通常在 20% 以下。如果某个时段突然超过 60%,基本可以确定在被刷,这时候应该临时把该场景切到「全部需要图形验证」,而不是简单地把限额调低——后者会误伤真实用户。

六、通道故障时的降级

短信通道是会挂的。运营商维护、服务商被攻击、模板被下架,任何一种都会让你的登录流程整体失效。所以发码接口必须有一个降级开关,能在不发布代码的情况下切到备用通道:

// config 里配置:primary / backup / none
$provider = getConfig('sms.provider');
$ok = false;
if ($provider !== 'none') {
    $ok = callSmsApiBy($provider, $mobile, $code, $scene);
}
if (!$ok && $provider === 'primary') {
    $ok = callSmsApiBy('backup', $mobile, $code, $scene);
}

备用通道最好和主通道不是同一家服务商的同一批资源,否则一起挂的可能性很大。有些服务商提供「多通道自动切换」,但切换逻辑在对方手里,你看不到也控制不了,个人站点建议自己写这十几行代码。

如果两条通道都挂,还有一个保底方案:给已有用户提供「邮箱验证」的兜底路径。新用户注册只能等,但老用户至少不会被锁在门外。

七、上线前必须测的五个场景

接口写完、本地跑通,不代表能用。上线前把这五个场景手工过一遍,每个都对应一类真实事故:

场景一,连点发送。快速点三次,应该只发出一条,另外两次返回「请稍后再试」。如果发了三条,说明冷却键没生效,可能是键名拼接错了或者 Redis 连接用的是另一个库。

场景二,输错五次。第五次输错后,验证码应该立刻失效,即使用正确的那一个也提示已失效。这验证的是错误计数和主动删除逻辑。

场景三,验证码复用。用正确验证码登录成功后,再提交同一个码,必须失败。这验证的是成功后的删除。

场景四,跨场景使用。用注册场景的码去走找回密码流程,必须失败。

场景五,手机号前后有空格。用户复制粘贴时经常带空格,trim 之后再校验和存储,否则会出现「同一个号被当成两个号」,限额全部失效。这一点极容易被忽略:键是 code:reg: 13800138000,和 code:reg:13800138000 是两个不同的键。

八、日志与成本监控

短信是少数「直接按条扣钱」的资源,一旦被刷,一晚上能刷掉几百上千块。所以必须有成本监控。

每次发码都记一行日志,包含时间、场景、手机号(中间四位打码)、是否成功、通道返回码、用户 IP、UA。这些字段是事后追查的全部依据,缺一个都会让复盘变得困难。日志不要写进 access log,单独一份文件,配 logrotate 按天切分,保留 30 天。

每天核对一次服务商后台的发送量与自己的日志条数。两者差得多,说明有绕过你的程序直接调用接口的情况,或者是通道侧重复计费。这个核对值得做成一个每日定时任务,差超过 5% 就发一封提醒给自己。

再加一个日预算硬上限:当日发送量超过平时均值的 3 倍,直接把该场景切到「仅图形验证 + 人工审核」,宁可牺牲一点转化,也不要在凌晨被刷走几千块。

九、小结

短信验证码这套流程,代码量其实很小,全部逻辑加起来不到两百行。真正难的是把这些细节都想到:随机数要安全、存储要有四个键、校验要删、比对要严格、场景要隔离、入口要防刷、通道要能切、日志要能查、预算要有上限。

九个环节里任何一个漏掉,都不会让功能不可用——这正是它危险的地方。功能正常跑着,漏洞却一直在那儿。所以这篇建议不要当成「实现教程」看,而是当成一份上线前的检查清单:逐条对照自己的代码,看哪一条没有做。

Last modification:October 28th, 2026 at 09:26 pm

Leave a Comment