短信验证码几乎贯穿了注册、登录、找回密码、支付确认等所有关键业务场景。很多开发者习惯把验证码明文存进Session或者数据库,甚至不设置有效期,这样做存在被重放攻击、暴力破解的风险。本文将从生成、加密、校验三个环节入手,用PHP实现一套完整的加密短信验证码方案。

一、生成安全的随机验证码
验证码的第一步是生成,很多老项目还在使用rand()或mt_rand()生成数字,这两个函数的随机性并不满足密码学安全要求。PHP提供了专门的密码学安全随机函数random_int(),它基于操作系统提供的安全随机源,可以有效避免验证码被预测。
下面是一个生成6位数字验证码的封装函数,同时限制了生成频率,防止恶意用户无限触发短信发送:
/**
* 生成6位数字验证码
* @return string
*/
function generateSmsCode(): string
{
$code = '';
for ($i = 0; $i < 6; $i++) {
$code .= random_int(0, 9);
}
return $code;
}
除了数字验证码,有些高安全场景会使用字母数字混合验证码,这时可以通过random_int()从预定义字符集中随机取字符。需要注意的是,字符集中应去掉容易混淆的字符,比如数字0和字母O、数字1和字母l,避免用户输入错误导致校验失败。
二、对验证码进行加密存储
验证码生成后不建议明文存储。如果服务器被攻破或日志泄露,明文验证码会直接暴露。常见做法是存储验证码的摘要值,而不是验证码本身。PHP提供了password_hash()和hash_hmac()两种方式。
hash_hmac()适合验证码这种短生命周期数据,计算速度快,且带密钥的哈希能防止彩虹表攻击:
define('SMS_SECRET_KEY', 'your-32-byte-secret-key-here!');
/**
* 对验证码进行HMAC加密
*/
function encryptSmsCode(string $code, string $phone): string
{
// 将手机号混入,防止验证码跨账号使用
return hash_hmac('sha256', $phone . '|' . $code, SMS_SECRET_KEY);
}
这里有一个关键细节:把手机号混入哈希计算中,可以有效防止一种攻击场景,即攻击者拿到A账号的验证码去尝试B账号。另外,如果需要存储后还能还原验证码原文(比如接入第三方短信平台时需要回显),可以采用AES-256-CBC对称加密:
/**
* AES加密验证码
*/
function aesEncrypt(string $code): string
{
$key = hash('sha256', SMS_SECRET_KEY, true);
$iv = random_bytes(16);
$encrypted = openssl_encrypt($code, 'AES-256-CBC', $key, OPENSSL_RAW_DATA, $iv);
// 将IV和密文一起编码存储
return base64_encode($iv . $encrypted);
}
/**
* AES解密验证码
*/
function aesDecrypt(string $data): string
{
$raw = base64_decode($data);
$iv = substr($raw, 0, 16);
$cipherText = substr($raw, 16);
$key = hash('sha256', SMS_SECRET_KEY, true);
return openssl_decrypt($cipherText, 'AES-256-CBC', $key, OPENSSL_RAW_DATA, $iv);
}
密钥一定要写在配置文件或环境变量中,绝对不能硬编码在代码里提交到版本库,否则加密形同虚设。
三、有效期管理与校验实现
验证码必须有时效性,一般设置为5分钟过期。推荐使用Redis存储,利用其自带的过期时间特性,无需手动清理过期数据。存储结构建议使用Hash,同时记录验证码摘要、发送时间和错误次数:
$redis = new Redis();
$redis->connect('127.0.0.1', 6379);
/**
* 保存验证码,有效期300秒
*/
function saveSmsCode(Redis $redis, string $phone, string $code): void
{
$key = 'sms_code:' . $phone;
$redis->hMSet($key, [
'sign' => encryptSmsCode($code, $phone),
'tries' => 0,
'created' => time(),
]);
// 5分钟自动过期
$redis->expire($key, 300);
}
/**
* 校验验证码
* @return bool
*/
function verifySmsCode(Redis $redis, string $phone, string $input): bool
{
$key = 'sms_code:' . $phone;
if (!$redis->exists($key)) {
return false; // 验证码不存在或已过期
}
$data = $redis->hGetAll($key);
// 错误次数超过5次,直接销毁验证码
if ($data['tries'] >= 5) {
$redis->del($key);
return false;
}
if (hash_equals($data['sign'], encryptSmsCode($input, $phone))) {
$redis->del($key); // 校验成功后立即销毁,防止重放
return true;
}
$redis->hIncrBy($key, 'tries', 1);
return false;
}
校验环节有几个容易被忽略的要点。第一,比较哈希值时必须使用hash_equals()而不是==或===,前者是恒定时间比较函数,能防止时序攻击。第二,验证码校验成功后要立即删除,同一条验证码只能使用一次,否则攻击者可以拿旧验证码反复重放。第三,限制错误尝试次数,超过5次就销毁验证码并锁定一段时间,这是抵御暴力破解6位数字验证码的关键措施。
四、完整业务流程与安全加固建议
把前面的函数串联起来,完整的业务流程是:用户请求发送验证码,先检查60秒内是否已发送过(防轰炸),然后生成验证码、加密存储到Redis、调用短信接口发送;用户提交验证码后,从Redis取出摘要做恒定时间比对,成功则销毁并放行业务。
除了基础实现,还有几点安全加固建议值得落实:一是对同一手机号设置每天发送上限,比如10条;二是对同一IP设置请求频率限制,防止刷短信接口造成资损;三是短信发送接口务必加上图形验证码或行为验证,挡住机器流量;四是密钥定期轮换,并且生产环境和测试环境使用不同的密钥。
验证码看似是一个小功能,但它是账户安全的第一道防线。使用random_int()保证随机性、HMAC或AES保证存储安全、Redis过期机制保证时效性、hash_equals()保证比对安全,这几个环节组合起来,就能构建出一套经得起考验的短信验证码系统。