注册、登录、找回密码、改密、绑定手机号……但凡是 Web 系统,短信验证码这个环节基本躲不掉。最近我在做一个会员注册模块,又把这套流程完整走了一遍——从表单页点“获取验证码”,到 PHP 后端收请求、调短信服务商接口发短信,再到用户回填验证码完成校验,整个链路看着简单,真写起来坑不少。所以这篇文章就把 PHP 短信验证码接口的完整实现方式整理出来,重点讲清楚“表单提交触发验证码发送”这条路径怎么落地,包括方案选型、核心代码、防刷策略、常见问题排查。不管你是刚接手某个项目的验证码模块,还是打算从零手写一套,都可以直接参考。
1. 先理清思路:短信验证码的完整链路和方案选型
1.1 一次“获取验证码”点击,后端到底做了什么
先说清楚整体流程。用户在表单页输入手机号,点击“获取验证码”按钮,这时候前端并不会直接去调短信服务商的 API,而是把手机号交给你自己的后端接口。后端拿到手机号之后,要先做几件事:
- 校验手机号格式是否合法。
- 检查这个手机号是不是在短时间内已经发过验证码,防止用户手滑狂点按钮,也防止有人恶意刷接口。
- 生成一个随机验证码(一般是 4-6 位数字)。
- 把验证码保存起来,同时设置过期时间(常见的是 5 分钟)。
- 调用短信服务商的 API,把验证码发到用户手机上。
- 把发送记录写入日志表,方便后续排查问题。
- 返回一个 JSON 结果给前端,前端根据结果决定是提示成功、还是提示“发送太频繁”,并启动按钮倒计时。
这里的核心点在于:短信验证码接口的本质不是一个孤立的功能,它需要配合“发送频率控制”和“验证码校验闭环”一起设计。只写一个能调通短信 API 的接口很容易,难在把它嵌进业务表单,同时保证安全性和稳定性。
1.2 表单提交触发,到底是哪种“触发”方式
标题里说“表单提交触发验证码发送”,这里需要细致区分两种实现路径,很多人刚开始会搞混:
- 方式 A:独立接口 + AJAX 触发。用户在表单里填手机号,点击“获取验证码”按钮时,前端用 AJAX 单独请求后端的发送接口。发送期间表单不刷新,用户继续填其他字段,收到验证码后一并提交。
- 方式 B:整个表单 submit 到后端,后端再发验证码并携带验证码结果渲染回页面。这种方式一般用于极简场景,但会导致页面刷新、表单内容丢失、体验很差。
实际项目中百分之九十九用的是方式 A。原因很直接:验证码发送和表单真正提交是两个时机,发送只需要手机号,而提交需要整张表单的数据。如果让整表 submit 来触发发送,意味着用户每点一次发送就把整张表单提交一次,不仅浪费,还会打断填写体验。
所以本文讲的“表单提交触发验证码发送”,指的是用户在表单页操作,触发了一次发送验证码的请求,而后端用独立接口接收处理。这也是面试里问“验证码怎么实现”时,对方期待听到的方案。
1.3 验证码存在哪里:Session、Redis 还是数据库
验证码必须存起来,因为发送之后,用户回填验证码时你得有地方比对。存储方案最常见的有三种:
- Session:实现最简单,
$_SESSION['sms_code'][$phone] = $code就完事。适合单机部署的小项目,代码里不用引入任何额外组件。缺点是多机部署时 Session 不共享,用户请求落到不同机器就会校验失败,需要额外做 Session 同步。 - Redis:生产环境最推荐。
SETEX sms:code:{phone} 300 {code}一条命令就带上过期时间,天然支持集群共享,还能顺便做频率计数。缺点是项目里必须装了 Redis 扩展,增加了部署成本。 - MySQL 日志表:用一张表记录手机号、验证码、过期时间、是否使用过。好处是每次发送都有留痕,方便审计和排查;缺点是每次校验都要查询数据库,高并发下有压力,还需要写清理过期数据的脚本。
我自己的偏好是:验证码状态用 Redis 或 Session 存,同时把发送记录同步写一份 MySQL 日志表。前者管校验,后者管排障。如果你做的是一个小型单体项目,没有 Redis,那么 Session + 数据库日志表的组合,每个月几千条发送量完全扛得住。
2. 对接短信服务商:发送前的关键准备
2.1 服务商 API 对接的核心要点
短信验证码不是你手机自己发出去的,你写的 PHP 代码只是把发送请求提交给短信服务商的接口,由服务商把短信投递到用户手机上。国内短信服务商的调用方式大同小异,一般就三步:构造请求参数、加上签名、请求对方 HTTP 接口。
关键参数通常包括几个:接入账号标识(一般叫 AppID 或 AccessKey)、密钥(AppSecret),以及你申请通过的短信模板。模板里一般有个变量占位符,比如“您的验证码为${code},5分钟内有效”,发送时你传入验证码的具体值,服务商把模板里的变量替换掉再下发。
签名是很多新手栽跟头的地方。服务商为了确认请求是你发的,要求把参数拼接后加上密钥做哈希,常见的有 MD5 签名和 HMAC-SHA256 签名。具体算法要看服务商文档,但核心思路一致:把请求参数按照指定顺序拼接。拼接时多数服务商要求先按参数名排序,然后把密钥拼接进去,再做一次摘要,最后把摘要作为 sign 字段一起提交。
注意:签名算法每家都不一样,别凭经验猜。拿到服务商文档之后,第一步就是照着文档写一个最小 demo,确认能收到短信,再封装到项目里。
2.2 建表与配置:给验证码留一条可追溯的路径
发送日志表我先建好。无论用不用数据库存验证码状态,发送日志都建议落库。表结构可以参考这样:
CREATE TABLE `sms_send_log` ( `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT, `phone` varchar(20) NOT NULL DEFAULT '' COMMENT '手机号', `scene` varchar(32) NOT NULL DEFAULT '' COMMENT '场景标识/注册/登录/找回密码', `code` varchar(10) NOT NULL DEFAULT '' COMMENT '验证码(加密存储)', `content` varchar(255) NOT NULL DEFAULT '' COMMENT '发送内容摘要', `status` tinyint(4) NOT NULL DEFAULT '0' COMMENT '0发送中 1成功 2失败', `provider_msg` varchar(255) NOT NULL DEFAULT '' COMMENT '服务商返回信息', `ip` varchar(46) NOT NULL DEFAULT '' COMMENT '请求IP', `created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), KEY `idx_phone_created` (`phone`, `created_at`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='短信发送日志表';这里有个细节值得说:验证码字段我建议不要明文存库,真要留痕可以存code的哈希值,或者等服务商短信发出后,只保留“已发送”状态,不保留验证码原文。因为日志表可能被开发测试人员和运维人员看到,明文验证码会带来安全隐患。如果为了排查短信是否发错内容,存一份模板变量内容摘要就够了。
配置文件单独放一个数组或类常量,后续改服务商、换模板、切换环境都方便:
return [ 'sms' => [ 'api_url' => 'https://sms.example.com/api/send', // 服务商接口地址 'app_id' => 'your_app_id', 'app_secret' => 'your_app_secret', 'sign_name' => '你的签名', 'template_id' => 'REGISTER_CODE', 'expire' => 300, // 验证码有效时间 5 分钟 'interval' => 60, // 同一手机号两次发送最小间隔 ], ];服务商接口地址我故意用了示例域名。实际上每家服务商都给一个 HTTPS 接口地址,一定用 HTTPS,验证码属于敏感信息,不能走明文 HTTP。
2.3 测试阶段不发真短信也能联调
有一个非常实用的技巧:在开发环境做短信接口时,隔离真实短信发送。你可以做一个“测试模式”:如果服务商配置里的api_url以mock://开头,或者系统配置APP_ENV=dev,就跳过真正 HTTP 调用,直接把验证码输出到日志文件,或者打印到接口返回值里。
if ($this->isDevMode()) { // 开发环境不真正发短信,验证码直接打到日志 error_log('[DEV SMS] phone=' . $phone . ' code=' . $code); return ['code' => 0, 'msg' => 'ok']; }这个做法好处很明显:开发环境没有短信服务商权限、没有测试手机号的时候,也能把整套流程跑通;联调短信排版问题的时候,还能直接看日志确认变量替换是否正确。等确认功能没问题了,再把配置切回真实接口做一次端到端验收。
3. 关键代码实现:PHP 后端接收并发送验证码
3.1 先封装一个短信发送类
不管用不用框架,我都建议把短信发送逻辑封装成一个独立的类,别把 HTTP 请求代码散落在接口文件里。封装的好处是以后换服务商只改一个文件的内部实现,接口层代码完全不用动。
下面是一个最小可用的短信发送类,基于 curl 封装了 HTTP POST 请求,并做了签名计算。不同服务商的参数名可能不同,但结构可以复用:
<?php class SmsService { protected $apiUrl; protected $appId; protected $appSecret; public function __construct($config) { $this->apiUrl = $config['api_url']; $this->appId = $config['app_id']; $this->appSecret = $config['app_secret']; } /** * 发送验证码短信 * * @param string $phone 手机号 * @param string $code 验证码 * @param string $templateId 模板ID * @return array */ public function sendCode($phone, $code, $templateId = 'REGISTER_CODE') { $timestamp = time(); $params = [ 'app_id' => $this->appId, 'phone' => $phone, 'template_id' => $templateId, 'template_params' => json_encode(['code' => $code, 'minute' => 5]), 'timestamp' => $timestamp, ]; $params['sign'] = $this->makeSign($params); $response = $this->httpPost($this->apiUrl, $params); return $this->parseResponse($response); } /** * 按服务商要求的规则生成签名 * 这里示意:按键名排序、拼 app_secret、做 md5 */ protected function makeSign($params) { ksort($params); $string = ''; foreach ($params as $key => $value) { $string .= $key . '=' . $value . '&'; } $string = rtrim($string, '&'); return md5($string . $this->appSecret); } protected function httpPost($url, $params) { $ch = curl_init($url); curl_setopt($ch, CURLOPT_POST, true); curl_setopt($ch, CURLOPT_POSTFIELDS, http_build_query($params)); curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); curl_setopt($ch, CURLOPT_TIMEOUT, 5); curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false); curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, false); $result = curl_exec($ch); $errno = curl_errno($ch); curl_close($ch); if ($errno) { return ['code' => -1, 'msg' => 'curl error: ' . $errno]; } $data = json_decode($result, true); if (!is_array($data)) { return ['code' => -2, 'msg' => 'invalid response: ' . $result]; } return $data; } protected function parseResponse($data) { // 服务商一般会有统一的业务状态码,0 表示成功 if (isset($data['code']) && (int)$data['code'] === 0) { return ['success' => true, 'msg' => '发送成功']; } return ['success' => false, 'msg' => $data['msg'] ?? '未知错误']; } }这里的签名算法是把参数按键名排序后拼成key=value字符串,再用密钥做 MD5,这是很多服务商采用的风格。务必以你对接的那家服务商文档为准,有的要求用 HMAC-SHA256,有的是在查询串上直接拼密钥,细节差异很大。
3.2 发送接口:参数校验、防刷、生成验证码、落库
接下来是核心的发送接口。这个接口接收前端 AJAX 请求,做完整的前置校验后调用SmsService发短信,并把日志写进表里。
<?php session_start(); header('Content-Type: application/json'); require_once 'SmsService.php'; require_once 'config.php'; $phone = trim($_POST['phone'] ?? ''); // 1. 校验手机号格式 if (!preg_match('/^1[3-9]\d{9}$/', $phone)) { echo json_encode(['code' => 400, 'msg' => '手机号格式不正确']); exit; } // 2. 防刷:同一手机号短时间内只能发一次 $lastSendTime = $_SESSION['sms_send_time'][$phone] ?? 0; $interval = $config['sms']['interval']; // 60 秒 if (time() - $lastSendTime < $interval) { $remain = $interval - (time() - $lastSendTime); echo json_encode(['code' => 429, 'msg' => '发送太频繁,请 ' . $remain . ' 秒后再试']); exit; } // 3. 防刷:同一 IP 每小时最多发送条数限制 $ip = $_SERVER['REMOTE_ADDR'] ?? ''; $hour = date('YmdH'); $ipCountKey = "sms_ip_count_{$ip}_{$hour}"; // 这个逻辑如果迁移到 Redis,就改成 INCR + EXPIRE 更简单 if (isset($_SESSION[$ipCountKey])) { if ($_SESSION[$ipCountKey] >= 10) { echo json_encode(['code' => 429, 'msg' => '当前网络环境发送次数过多']); exit; } $_SESSION[$ipCountKey]++; } else { $_SESSION[$ipCountKey] = 1; } // 4. 生成验证码 $code = (string) random_int(100000, 999999); // 5. 保存验证码:Session + 日志表双写 $_SESSION['sms_code'][$phone] = [ 'code' => $code, 'expire' => time() + $config['sms']['expire'], 'used' => false, ]; $_SESSION['sms_send_time'][$phone] = time(); // 6. 调用短信服务商发送 $sms = new SmsService($config['sms']); $result = $sms->sendCode($phone, $code); // 7. 记录日志表 $pdo = new PDO('mysql:host=localhost;dbname=your_db', 'user', 'pass'); $stmt = $pdo->prepare( 'INSERT INTO sms_send_log (phone, scene, code, content, status, provider_msg, ip) VALUES (?, ?, ?, ?, ?, ?, ?)' ); $stmt->execute([ $phone, 'register', password_hash($code, PASSWORD_DEFAULT), // 日志里不要存验证码明文 '验证码为:' . $code . ',5分钟内有效', $result['success'] ? 1 : 2, $result['msg'], $ip, ]); // 8. 返回结果 if ($result['success']) { echo json_encode(['code' => 0, 'msg' => '验证码已发送']); } else { echo json_encode(['code' => 500, 'msg' => '短信发送失败,请稍后重试']); }防刷部分我这里用了 Session 计数。生产环境更推荐用 Redis 的INCR+EXPIRE,把计数和过期时间交给 Redis 原子操作处理,否则高并发下 Session 本身也可能成为瓶颈。
提示:Session 里保存验证码时,不建议把“是否已使用”这个状态忽略掉。校验成功后必须把
used置为 true,否则同一个验证码可以被反复提交,产生安全漏洞。
3.3 前端表单:按钮倒计时与交互细节
前端部分,我以原生 JavaScript + fetch 为例,不依赖 jQuery。HTML 结构大致如下:
<form id="registerForm"> <div class="form-item"> <label>手机号</label> <input type="tel" id="phone" name="phone" maxlength="11" placeholder="请输入手机号" /> </div> <div class="form-item"> <label>验证码</label> <div class="code-row"> <input type="text" id="code" name="code" maxlength="6" placeholder="6位验证码" /> <button type="button" id="sendCodeBtn">获取验证码</button> </div> </div> <div class="form-item"> <label>密码</label> <input type="password" id="password" name="password" placeholder="设置登录密码" /> </div> <button type="submit">注册</button> </form>按钮的类型必须是type="button",不能是type="submit",这样才能保证点击发送验证码时不会触发整表单提交。这一点新手很容易踩坑,我见过不少项目就是因为按钮类型写成了 submit,结果点一次验证码整页就刷新了。
JS 逻辑:
const sendBtn = document.getElementById('sendCodeBtn'); const phoneInput = document.getElementById('phone'); let countdownTimer = null; sendBtn.addEventListener('click', function () { const phone = phoneInput.value.trim(); if (!/^1[3-9]\d{9}$/.test(phone)) { alert('请先输入正确的手机号'); return; } // 请求发送验证码 fetch('/api/send_sms.php', { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: 'phone=' + encodeURIComponent(phone) }) .then(res => res.json()) .then(data => { if (data.code === 0) { startCountdown(60); } else { alert(data.msg); } }) .catch(() => { alert('网络异常,请稍后重试'); }); }); function startCountdown(seconds) { sendBtn.disabled = true; let remain = seconds; sendBtn.textContent = remain + '秒后可重发'; countdownTimer = setInterval(() => { remain--; sendBtn.textContent = remain + '秒后可重发'; if (remain <= 0) { clearInterval(countdownTimer); sendBtn.disabled = false; sendBtn.textContent = '获取验证码'; } }, 1000); }倒计时这里有个细节:如果用户刷新了页面,倒计时就断了,用户可以立刻再次点击发送。这其实是合理的,因为后端接口里还有 60 秒间隔控制,倒计时只是提升用户体验的前端表现,真正的防刷靠后端。倒计时的时间建议以服务端返回的剩余时间为准,而不是前端写死 60,这样更稳妥。
4. 验证码校验与表单闭环
4.1 校验接口的实现
用户收到验证码,填进表单,最终提交注册信息时,后端必须在业务处理之前校验验证码正确性和时效性。校验逻辑在业务代码里通过公共函数调用,不要在注册逻辑里临时拼一段校验代码。
<?php session_start(); function verifySmsCode($phone, $code) { $phone = trim($phone); $code = trim($code); if (!preg_match('/^1[3-9]\d{9}$/', $phone) || !preg_match('/^\d{6}$/', $code)) { return ['valid' => false, 'msg' => '手机号或验证码格式不正确']; } if (!isset($_SESSION['sms_code'][$phone])) { return ['valid' => false, 'msg' => '请先获取验证码']; } $record = $_SESSION['sms_code'][$phone]; if ($record['used']) { return ['valid' => false, 'msg' => '验证码已被使用']; } if ($record['expire'] < time()) { // 过期后顺便清掉,避免占用内存 unset($_SESSION['sms_code'][$phone]); return ['valid' => false, 'msg' => '验证码已过期,请重新获取']; } if ($record['code'] !== $code) { return ['valid' => false, 'msg' => '验证码错误']; } // 校验通过,标记为已使用 $_SESSION['sms_code'][$phone]['used'] = true; return ['valid' => true, 'msg' => 'ok']; }校验的实体逻辑就是这样。注意比较验证码时建议精确匹配,不要做模糊匹配。有人为了让用户更友好,校验时允许用户输入空格或自动去掉首尾空格,这可以,但内部存储和比对必须基于纯数字串。
另外一个安全细节:验证码尝试次数也要限制。最佳实践是同一手机号最多允许错误 5 次,超过之后不管输入对不对都返回“验证码错误”,同时把该验证码作废,逼用户重新获取。防止有人暴力枚举 6 位验证码,理论上全空间只有 100 万种组合,没有次数限制的话脚本跑一段时间就能撞出来。
4.2 把校验嵌进注册 / 登录流程
注册接口里,验证码校验放在最前面,校验通过后再继续处理账号创建。伪代码如下:
<?php require_once 'verify_sms_code.php'; session_start(); $phone = trim($_POST['phone'] ?? ''); $code = trim($_POST['code'] ?? ''); $password = $_POST['password'] ?? ''; // 1. 先验证码校验,不通过直接返回,不进入业务逻辑 $check = verifySmsCode($phone, $code); if (!$check['valid']) { echo json_encode(['code' => 400, 'msg' => $check['msg']]); exit; } // 2. 校验密码、创建账号等业务逻辑 // $pdo->prepare('INSERT INTO user ...'); echo json_encode(['code' => 0, 'msg' => '注册成功']);这个前置校验的顺序不能乱。如果先走了一堆业务逻辑再回来校验验证码,不仅浪费资源,还可能出现账号创建完成但验证码校验失败的脏数据场景。
登录场景也类似,只是把创建账号换成校验账号密码。这里有一个小技巧:登录时验证码校验通过后,最好把验证码对应的手机号绑定到当前会话,用$_SESSION['sms_verified_phone'] = $phone记录,后续修改密码之类的操作可以复用这个标记,避免用户再次输入验证码。
5. 常见问题与排查技巧实录
5.1 短信发不出去的 5 个典型原因
做短信验证码功能,短信发不出去是头号问题。根据我踩过的坑,原因集中在下面几个方向:
- 模板变量和模板内容不一致。申请模板时写的是“您的验证码为${code}”,请求里传的参数名不是
code,或者多传了模板里没有的参数,服务商直接拒绝发送。 - 签名不匹配。短信内容中带的签名必须与服务商平台审核通过的签名一致。常见问题是测试环境填了正式签名,或者签名前后带了奇怪的符号。
- 单日发送量超限。很多服务商对单个验证码模板、单手机号都有日发送上限,超了之后接口报业务错误,不会真的发出去。
- 手机号被服务商标记为异常号。测试手机号如果经常发验证码,容易被限频或加黑名单,这时换个号码测试往往立刻能发出去。
- 接口调用参数编码问题。
template_params里面是 JSON 字符串,如果用了中文和特殊字符,没有做 urlencode,HTTP 请求解析失败。
排查时看日志表里provider_msg字段非常关键。服务商返回的错误码一般都能直接告诉你原因,别只看“发送失败”四个字就去改代码。
5.2 验证码过期、失效和会话丢失等细节
验证码校验时遇到的坑,很大一部分发生在 Session 机制上。比如本地开发正常,部署到服务器上验证码总是校验失败,先看 Session 是否正常工作,尤其是 Session 目录有没有写权限。再比如用户打开两个浏览器标签页,一个获取验证码并校验成功,另一个又用同一个手机号发起另一笔业务,就会提示验证码已被使用——因为校验成功之后旧验证码已经失效。
解决方案是把验证码状态和业务场景绑定:场景一成功不影响场景二,每个场景维护独立的验证码状态。用 Redis 的 key 设计就是sms:{scene}:{phone},而不是单一的sms:{phone}。
5.3 上线后的日志与监控建议
短信验证码是入口级功能,一旦出问题,用户进不来,影响面很大。上线前一定要把下面几件事做好:
- 日志表里记录
provider_msg原始返回内容,排查问题时能直接定位。 - 对发送失败率做一个简单的监控。虽然不用上什么重武器,但每天看一下失败记录总数和失败原因分布就够了。如果某天失败率突然升高,去服务商控制台看是否欠费或模板被冻结。
- 短信接口的超时时间要设置短一点,我一般设 5 秒。服务商故障时,不能让用户干等,前端拿到超时结果后提示“系统繁忙,请稍后重试”。
- 同一个手机号每天的发送上限必须控制。服务商本身有上限,但如果被脚本刷爆了,不仅浪费钱,你的服务商账号还可能被风控,影响所有业务短信。
个人实际体会:短信验证码这套东西,代码量其实没多少,真正要做到生产稳定,功夫全在下游——防刷策略是否到位、日志是否完整、服务商接口兜底是否可靠。我建议任何项目接入短信验证码之前,先把发送日志表建好,把接口统一封装好,把限频逻辑写扎实。这几个基础工作做扎实了,后面无论是换短信服务商、加滑块验证、还是接入风控系统,都只是在既有框架里加一个环节,不会伤筋动骨。如果你刚准备在项目里写短信验证码,照着上面的链路从头捋一遍,踩坑概率会小很多。