
PHP随机数不够随机怎么办?安全场景下的生成方法
在 PHP 程序里,随机数看起来很简单:调用 rand(),拿到一个整数,再把它用作验证码、临时密码或令牌。但“看起来随机”不等于“攻击者无法预测”。在抽奖演示、随机排序等场景中,普通随机函数通常够用;一旦随机值承担身份验证、授权或机密保护的职责,就必须使用适合安全场景的随机数生成方式。
本文说明 PHP 随机数 API 的适用范围和关键边界,并给出可以直接运行的代码示例。文中的安全建议针对常见 Web 应用;随机数只是整体安全机制的一环,不能代替访问控制、传输加密、有效期管理和防暴力破解。
先区分两种“随机”
普通伪随机数生成器通过算法产生数列。它可能满足模拟、洗牌或游戏中的统计需求,但如果输出可观察、算法状态可推测,攻击者就可能预测后续结果。这里的“伪”并不意味着结果一定明显重复,而是说明它不适合用来保护秘密。
密码学安全随机数生成器(CSPRNG)会使用操作系统提供的安全随机源,并通过适合密码学的机制产生输出。应用代码通常不需要自行实现熵收集或随机数算法,而应调用 PHP 提供的安全接口。现代 PHP 中,用于安全场景的核心函数是 random_int() 和 random_bytes()。
| 用途 | 建议 |
|---|---|
| 模拟、非安全用途的演示随机数 | 按需求选用普通随机函数;不要把结果用于授权或保密。 |
| 安全范围内的整数,例如抽取验证码数字 | random_int() |
| 令牌、密钥材料或随机字节 | random_bytes() |
不要把 rand()、mt_rand() 当作安全随机数
rand()、mt_rand() 和 shuffle() 面向的是一般随机处理,不应拿来生成密码重置令牌、会话秘密、验证码答案或其他安全凭据。即使输出经过十六进制或 Base64 编码,编码也不会让原本不安全的随机数变安全。
<?php
// 不适合安全令牌、验证码答案或重置密码链接:
$token = bin2hex(random_bytes(32));
// 也不要这样生成安全令牌:
$badToken = md5(uniqid((string) mt_rand(), true));
上面第一种写法是安全生成示例,第二种是反例。uniqid() 旨在生成基于时间的唯一标识,并不是密码学随机数;再拼上 mt_rand() 或做哈希,也不能补足不可预测性。哈希会改变数据表现形式,但不会凭空增加随机熵。
生成安全整数:random_int()
random_int($min, $max) 返回指定范围内的安全随机整数,范围两端都包含在内。例如 random_int(0, 9) 可能返回 0,也可能返回 9。需要整数选择时,通常不必自己把随机字节转换成整数,直接使用这个函数更简单。
<?php
$number = random_int(1, 100);
echo $number;
限制也很重要:上下界必须是有效整数,且最小值不能大于最大值。random_int() 的整数范围受当前 PHP 平台的整数能力约束;不要为了处理超出平台整数范围的值,把参数强行转换成浮点数。需要更大的随机数据时,应使用字节接口,而不是把超大范围硬塞进整数函数。
函数无法从系统随机源取得足够数据时会抛出异常。PHP 8 及更新版本中,相关随机数异常类型为 Random\RandomException;旧版 PHP 的异常细节可能不同。无论部署在哪个版本,都不应在失败时退回到 rand() 或 mt_rand()。对安全用途而言,宁可明确失败,也不要悄悄降级。
生成安全字节:random_bytes()
random_bytes($length) 返回指定长度的二进制字符串。二进制数据可能含有不可打印字符或空字节,因此不适合直接放入 URL、表单或日志中。需要传输或存储为文本时,再选择十六进制或 Base64 URL 安全编码。
<?php
$bytes = random_bytes(32); // 32 个随机字节
$hex = bin2hex($bytes); // 64 个十六进制字符
echo $hex;
传给 random_bytes() 的长度必须是正整数;长度为 0 或负数不是有效请求。PHP 8 起,长度小于 1 会触发 ValueError。若随机源不可用,函数会抛出异常。生产代码应确保部署环境使用受支持的 PHP 版本,并测试异常处理路径,而不是尝试用普通随机函数兜底。
随机字节的长度和最终文本长度不同。比如 32 字节转为十六进制后是 64 个字符;编码后的字符数不是熵,也不意味着熵翻倍。32 个均匀随机字节包含 256 位随机数据,转成十六进制只是以较长的文本表示同一批数据。
令牌示例:生成、传输与验证
下面生成 32 字节随机令牌,并编码成 URL 可用的 Base64 URL-safe 字符串。标准 Base64 可能包含 +、/ 和填充符 =;这里将其替换为 URL 安全字符并去掉末尾填充。解码时需补回填充,并启用严格模式。
<?php
function createToken(int $bytes = 32): string
{
if ($bytes < 1) {
throw new InvalidArgumentException('字节长度必须大于 0');
}
$base64 = base64_encode(random_bytes($bytes));
return rtrim(strtr($base64, '+/', '-_'), '=');
}
$token = createToken();
echo $token;
这段代码对业务参数做了正数检查,但并未设置一个适用于所有项目的最大值。实际应用可以依据令牌用途设定合理上限,避免调用者要求分配过大的内存。不要把外部输入未经校验直接作为字节长度。
如果令牌要通过 URL 发送,应使用 HTTPS,并避免让它意外进入第三方分析系统、Referer、访问日志或截图。令牌一旦泄露,随机性再强也无法保护它。重置密码令牌还应绑定用户、用途与过期时间,并在成功使用后立即失效。
对于高价值的一次性令牌,数据库通常不必保存原始令牌。可以只保存令牌的哈希值,用户提交令牌时再计算哈希并比较。以下示例展示核心做法,数据库读写、用户绑定、事务以及时间管理需由应用补齐。
<?php
$token = createToken();
$tokenHash = hash('sha256', $token);
// 将 $tokenHash、用户标识、用途和过期时间存入数据库。
// 把原始 $token 放进 HTTPS 链接发给用户。
// 收到令牌后计算:hash('sha256', $submittedToken)
// 再与数据库中的哈希值比较,并检查未过期、未使用。
对于固定长度的 SHA-256 哈希值,比较时可以使用 hash_equals(),避免普通字符串比较在某些场景下造成与内容相关的时间差异。它不能代替权限检查、过期检查或防止暴力尝试的措施。
<?php
$submittedHash = hash('sha256', $submittedToken);
if (hash_equals($storedTokenHash, $submittedHash)) {
// 仍需检查用户、用途、有效期和是否已使用。
}
数字验证码:不要有取模偏差
需要生成六位数字验证码时,常见做法是直接在 0 到 999999 之间取随机整数,再按六位格式输出。验证码允许以 0 开头,因此要使用格式化函数保留前导零;不能把它当作整数传输,否则前导零会消失。
<?php
$code = str_pad(
(string) random_int(0, 999999),
6,
'0',
STR_PAD_LEFT
);
echo $code;
也可以用 random_int(100000, 999999) 生成恰好六位且首位非零的数字,但这样可选结果只有 900000 个;前一种方式有 1000000 个结果,适合“六位数字字符串”的常见定义。具体选择应与产品要求一致。
不建议先生成一个大随机数,再用取模得到验证码数字,例如 random_int(0, PHP_INT_MAX) % 10。当随机数可取值数量不能被 10 整除时,各余数出现概率会有微小差异。虽然某些场景下差异不大,但直接调用 random_int(0, 9) 清楚且避免了这类偏差。
短信或邮件验证码的安全性不只取决于随机生成。还需要设置较短的有效期、限制单个账号和 IP 的发送频率、限制验证尝试次数,并在成功后作废。验证码通常只有有限个可能值,若不限制尝试次数,攻击者可以反复猜测。短信验证码也不能单独视为高强度身份验证,因为短信通道可能遭遇转移、拦截或社交工程攻击。
密码生成:均匀选字符,并明确策略
如果业务确实需要系统生成密码,应从明确的字符集合中安全地选择字符。下面示例使用 random_int() 为每个位置选择索引。它能避免使用“随机整数取模”时的分布偏差。
<?php
function generatePassword(int $length = 20): string
{
if ($length < 1 || $length > 256) {
throw new InvalidArgumentException('长度须在 1 到 256 之间');
}
$alphabet = 'ABCDEFGHJKLMNPQRSTUVWXYZ'
. 'abcdefghijkmnopqrstuvwxyz'
. '23456789!@#$%^&*()-_=+';
$password = '';
$lastIndex = strlen($alphabet) - 1;
for ($i = 0; $i < $length; $i++) {
$password .= $alphabet[random_int(0, $lastIndex)];
}
return $password;
}
echo generatePassword(20);
代码设置 256 作为示例上限,是为了避免不必要的大量工作;它不是 PHP 函数的通用参数上限,也不代表所有产品都应采用这个长度。实际限制应结合业务、存储字段、下游系统和界面要求设计。字符集合中排除了容易混淆的字符,并加入了一些符号;如果目标系统不接受某些字符,应调整集合并测试兼容性。
按字符集合逐位独立抽取,并不能保证每类字符都出现。例如密码可能没有大写字母。若策略要求至少包含一位大写、一位小写、一位数字和一位符号,应先分别从各类别抽取,再用安全随机方式打乱整体顺序;还要避免“随机生成后不符合策略就不断重试”造成难以评估的行为。更重要的是,密码策略应符合实际安全需求,不能把强制复杂度规则误当作长度、限速和安全存储的替代品。
用户密码通常不应由服务端生成固定密码后明文发送。更合适的做法是让用户设置密码,服务端用 PHP 的 password_hash() 存储密码哈希,并在登录时使用 password_verify() 校验。password_hash() 用于密码哈希,不是随机令牌生成函数;不要将两类需求混为一谈。
随机洗牌与抽样
如果需要以安全方式打乱一个 PHP 数组,不要直接调用面向一般用途的 shuffle() 来满足安全抽签要求。可以使用 Fisher–Yates 洗牌,并用 random_int() 选择交换位置。
<?php
function secureShuffle(array &$items): void
{
for ($i = count($items) - 1; $i > 0; $i--) {
$j = random_int(0, $i);
[$items[$i], $items[$j]] = [$items[$j], $items[$i]];
}
}
$items = ['甲', '乙', '丙', '丁'];
secureShuffle($items);
print_r($items);
这种写法假设 PHP 版本支持数组解构赋值(PHP 7.1 起)。它会修改传入数组的元素顺序,并保留数组键名这一点需要留意:交换的是键对应的值,键不会被重新编号。若业务要求连续数字索引,可在洗牌后调用 array_values()。用于正式抽奖时,还要记录参与名单、抽取规则和结果,避免仅凭随机函数就宣称抽奖流程公平可信。
适用边界:随机不等于唯一,也不等于保密
随机令牌不自动保证唯一。假设令牌空间很大,随机碰撞的概率通常可以很低,但它并非数学上的零。重要业务可以给令牌字段建立唯一索引,插入发生冲突时重新生成;不要单纯依赖“随机到足够长,所以永远不重复”。
随机也不等于保密。应用如果把令牌写入公开日志、错误页面或分析事件,攻击者拿到令牌后仍可能使用它。对凭据要实行最小暴露:不记录原文、不放进不必要的日志,不在响应中额外回显,不把秘密提交到源码仓库。
随机数的长度需要按用途选择。长度太短会让猜测更容易;长度过长则增加传输、存储和兼容成本。令牌生成时可以用随机字节长度直接表达所需强度。例如 32 个随机字节提供 256 位随机数据;最终格式长度会因编码而增加。不要通过截短令牌来满足界面限制,除非已重新评估截短后的安全强度。
异常处理:失败时不要降级
安全随机数 API 的一个重要特性是:无法可靠生成随机数据时会报错,而不是假装成功。应用应在合适的边界记录不含秘密的错误信息,并返回通用服务错误或停止当前操作。不要把异常文本、堆栈或随机令牌返回给普通用户。
<?php
try {
$token = bin2hex(random_bytes(32));
} catch (Throwable $e) {
// 向服务端日志记录必要的诊断信息,但不要记录令牌。
error_log('安全随机数生成失败');
http_response_code(503);
exit('暂时无法完成请求,请稍后再试。');
}
这里捕获 Throwable 是为了示范在边界处统一处理异常和错误;并不意味着每个小函数都应捕获所有问题。若捕获后继续执行其他逻辑,要确保没有在生成失败时使用未初始化或默认值。更不要写成“random_bytes() 失败就改用 mt_rand()”,否则部署故障会直接变成安全降级。
常见错误检查清单
- 用
rand()、mt_rand()生成令牌:改用random_bytes(),整数范围需求使用random_int()。 - 把随机数哈希后当作安全随机数:哈希不提供新的熵;应从安全随机源开始。
- 用取模映射到小范围:改用
random_int($min, $max),避免自行处理分布偏差。 - 把二进制随机字节直接拼入 URL:先编码,并确保 URL 使用 HTTPS。
- 忘记验证码前导零:以字符串形式保留固定宽度,不要把验证码当作需要计算的整数。
- 令牌永不过期或可重复使用:设置用途、过期时间和一次性消费规则。
- 随机令牌明文长期存储:高价值场景可只保存其哈希,并在使用时校验。
- 把唯一性寄托在随机性上:对需要唯一的数据库字段增加唯一约束,并处理冲突。
- 生成失败后退回弱随机函数:应失败关闭,检查运行环境和 PHP 配置。
- 只测试输出“看起来够随机”:统计测试不能证明安全性;应检查 API、参数、生命周期、访问控制和限速。
PHP 版本与部署检查
random_int() 与 random_bytes() 从 PHP 7.0 起提供。若项目仍运行更旧版本,应优先升级到受支持版本,而不是复制一段自制随机数代码。旧版环境可能需要经过维护的兼容库;应核实其实现、依赖和平台支持,不要把自写的 OpenSSL 包装或系统时间拼接当成安全替代方案。
上线前至少检查:生产与测试环境运行的 PHP 版本一致且受支持;部署平台能够访问系统安全随机源;随机函数异常会被妥善处理;令牌和验证码的过期、限次、一次性使用逻辑已实现;日志与监控不会泄露原始秘密。容器、受限运行环境或异常的系统配置可能导致随机源不可用,应该在部署测试中发现,而不是在业务高峰时才暴露。
结语
在 PHP 中,安全随机数的选择并不复杂:需要整数就用 random_int(),需要字节就用 random_bytes()。真正容易出错的地方往往在周边:用弱函数凑令牌、错误处理时降级、验证码不做限速、令牌不设过期时间,或者把随机性误认为唯一性与保密性。
把安全随机数 API 与明确的参数边界、异常处理、令牌生命周期和访问限制一起设计,才能让随机值在实际业务中发挥应有作用。



