CWE-331: Insufficient Entropy - PHP
Overview
Insufficient entropy is a question about the number of unpredictable bits in a value, not about which function produced it. In PHP it shows up in two shapes. The first is a seeded generator: mt_srand(time()) caps everything MT19937 goes on to produce at the range of time(), which is 86,400 candidates for a known day - measured at 0.23 seconds to search on PHP 8.5. The second is an output that is simply too small - a six-digit code, random_bytes(4), a twelve-character alphanumeric string - where the generator is already correct and the value still falls to a brute-force. Replacing mt_rand() with random_int() fixes the first shape and leaves the second untouched, which is why a re-scan can come back clean on code that has not changed the number that matters.
Primary Defence: Size the value first - at least 16 bytes of entropy for a token and 32 for key material - then draw it with random_bytes() or random_int() (PHP 7.0+), which seed themselves from the OS and cannot be seeded by the caller.
The related finding that the generator itself is not cryptographic - mt_rand(), rand(), lcg_value(), str_shuffle() - is CWE-338. The two are usually reported together and are both covered below, because the same line of code is normally guilty of both.
Common Vulnerable Patterns
Using mt_rand() for token generation
<?php
// VULNERABLE - Predictable token generation
function generateToken() {
$chars = '0123456789abcdef';
$token = '';
for ($i = 0; $i < 32; $i++) {
$token .= $chars[mt_rand(0, strlen($chars) - 1)];
}
return $token;
}
Why this is vulnerable:
mt_rand()uses MT19937, which is fast but not cryptographically secure.- An attacker can recover the internal state from enough outputs and predict future values.
rand()is just an alias ofmt_rand()in PHP 7.1+, so it is equally weak.
Time-based random seeding
Why this is vulnerable: This is the shape MITRE's own CWE-331 entry uses,
and the argument is about arithmetic rather than about MT19937. mt_srand()
takes an int, and everything the generator produces afterwards is a function
of it - so the output space is the seed space, not the algorithm's 2^19937
period. time() at one-second granularity gives roughly 86,400 candidate seeds
for a known day and about 2.6 million for a known month, both of which an
attacker replays offline in seconds by calling mt_srand($t); mt_rand(0,
1000000); for each candidate and comparing. Widening mt_rand(0, 1000000) to a
larger range changes nothing, because the range was never the constraint.
getmypid(), an auto-increment user ID and a request counter all fail the same
way and are smaller still.
Using rand() for encryption keys
<?php
// VULNERABLE - Using rand for encryption key
$encryptionKey = '';
for ($i = 0; $i < 32; $i++) {
$encryptionKey .= chr(rand(0, 255));
}
Why this is vulnerable:
- Encryption keys must be unpredictable;
rand()/mt_rand()are deterministic. - If the PRNG state is recovered, generated keys can be reproduced, and anything encrypted under them read.
Using mt_rand() for IVs
<?php
// VULNERABLE - Random IV generation
$iv = '';
for ($i = 0; $i < 16; $i++) {
$iv .= chr(mt_rand(0, 255));
}
Why this is vulnerable:
- CBC IVs must be unpredictable; AEAD nonces such as AES-GCM nonces must be unique and never repeat under the same key.
mt_rand()output is predictable and can repeat across runs.- Nonce reuse under the same key breaks AES-GCM.
Using mt_rand() for password reset tokens
<?php
// VULNERABLE - Password reset token
$resetToken = '';
for ($i = 0; $i < 6; $i++) {
$resetToken .= mt_rand(0, 9);
}
Why this is vulnerable:
- Reset tokens grant account access, so they must be unguessable.
- Six digits provides ~20 bits of entropy, which is brute-forceable.
mt_rand()is predictable, so recovering its state yields the next token without searching those 20 bits at all.
Using uniqid() for API keys
<?php
// VULNERABLE - API key generation
function generateApiKey() {
return uniqid('api_', true); // Based on timestamp!
}
Why this is vulnerable:
uniqid()is time-based and designed for uniqueness, not secrecy.more_entropystill relies on weak randomness and timing data.- Predictable IDs allow attackers to guess API keys.
Weak sources that do not look like random number generators
The functions above announce themselves. These do not, which is why they survive review:
<?php
// VULNERABLE - all of these draw on the same weak generator as mt_rand()
$password = substr(str_shuffle('abcdefghijklmnopqrstuvwxyz0123456789'), 0, 12);
$winner = array_rand($eligibleUsers); // picks an index using mt_rand()
shuffle($lotteryEntries); // shuffles in place using mt_rand()
$token = sha1(lcg_value()); // combined linear congruential generator
Why this is vulnerable: str_shuffle(), shuffle(), and array_rand()
all draw from the same Mersenne Twister state as mt_rand(), so hashing or
shuffling the output does not add entropy - an attacker who recovers the state
reproduces the result. lcg_value() is a pair of linear congruential
generators and is weaker still. Hashing a weak value with sha1() or md5()
makes it look random without making it unpredictable.
Secure Patterns
Using random_bytes() and random_int() (PHP 7.0+)
<?php
// SECURE - Session token generation (128+ bits)
function generateSessionToken() {
/**
* Generate cryptographically secure session token
* 16 bytes = 128 bits, converted to 32 hex chars
*/
return bin2hex(random_bytes(16));
}
// SECURE - URL-safe token (base64-encoded)
function generateUrlSafeToken() {
/**
* Generate URL-safe token for password resets, CSRF, etc.
* 32 bytes = 256 bits
*/
$bytes = random_bytes(32);
return rtrim(strtr(base64_encode($bytes), '+/', '-_'), '=');
}
// SECURE - Cryptographic key generation
function generateEncryptionKey($keySize = 32) {
/**
* Generate AES-256 key (256 bits = 32 bytes)
*/
return random_bytes($keySize);
}
// SECURE - CSRF token
function generateCsrfToken() {
/**
* Generate CSRF protection token
*/
$token = bin2hex(random_bytes(32));
$_SESSION['csrf_token'] = $token;
return $token;
}
// SECURE - API key generation
function generateApiKey() {
/**
* Generate API key with 256 bits of entropy
* 32 bytes = 256 bits
*/
$bytes = random_bytes(32);
return rtrim(strtr(base64_encode($bytes), '+/', '-_'), '=');
}
// One-time code delivered out of band. NOT a token: 6 digits is
// 6 * log2(10) = 19.9 bits, so the entropy is not what makes it safe -
// the attempt limit is. This is the same 20 bits the reset-token example
// above is marked VULNERABLE for; what changes is that the code is
// single-use, expires in minutes, and the caller is locked out after a
// handful of tries. Ship all three or raise the length.
function generateOtp(int $length = 6): string {
$code = '';
for ($i = 0; $i < $length; $i++) {
$code .= random_int(0, 9); // rejection sampling, no modulo bias
}
return $code;
}
// SECURE - Alphanumeric code, sized to 128 bits
function generateAlphanumericCode(int $length = 25): string {
/**
* 36-character alphabet, so each character carries log2(36) = 5.17 bits.
* 25 characters is 129 bits; 12 characters would be only 62.
*/
$chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
$charsLength = strlen($chars);
$code = '';
for ($i = 0; $i < $length; $i++) {
$code .= $chars[random_int(0, $charsLength - 1)];
}
return $code;
}
Why this works:
random_bytes()uses OS-backed CSPRNG sources, producing unpredictable output.random_int()draws with rejection sampling rather than a modulo reduction, so every digit or character in the alphabet is equally likely. Reducing a random byte with% 10would not: 256 is not a multiple of 10, so digits 0-5 come up 26 times per 256 bytes against 25 times for 6-9.- Encodings like hex/base64 preserve entropy while making tokens usable, because 16 and 64 both divide 256. A smaller alphabet does not, which is why the alphanumeric code is sized by
log2(36)rather than by character count. - The byte counts are chosen against what the value protects rather than against how the string looks: 16 bytes is 128 bits for a session token, 32 bytes is 256 bits for a long-lived credential, and the two encodings render those as 32 and 43 characters respectively.
- The functions fail closed if secure randomness is unavailable.
Complete encryption example with secure randomness
<?php
declare(strict_types=1);
final class SecureEncryption
{
private const CIPHER = 'aes-256-gcm';
private const NONCE_LEN = 12; // 96-bit nonce recommended for GCM
private const TAG_LEN = 16; // 128-bit tag (full length)
private const SALT_LEN = 16; // 128-bit salt is OK; consider 32 if you prefer
private const KEY_LEN = 32; // 256-bit key
private const PBKDF2_ITERS = 600000; // OWASP PBKDF2-HMAC-SHA256 guidance
public static function generateSalt(int $size = self::SALT_LEN): string
{
if ($size < 16) {
throw new InvalidArgumentException('Salt too short');
}
return random_bytes($size);
}
public static function deriveKey(string $password, string $salt, int $keyLength = self::KEY_LEN): string
{
if ($keyLength !== self::KEY_LEN) {
throw new InvalidArgumentException('Unexpected key length');
}
return hash_pbkdf2(
'sha256',
$password,
$salt,
self::PBKDF2_ITERS,
$keyLength,
true
);
}
/**
* Returns a single base64 string containing: salt || nonce || tag || ciphertext
*/
public static function encryptToBase64(string $plaintext, string $password): string
{
$salt = self::generateSalt();
$key = self::deriveKey($password, $salt);
$nonce = random_bytes(self::NONCE_LEN);
$tag = '';
$ciphertext = openssl_encrypt(
$plaintext,
self::CIPHER,
$key,
OPENSSL_RAW_DATA,
$nonce,
$tag,
$aad = '',
self::TAG_LEN
);
if ($ciphertext === false) {
throw new RuntimeException('Encryption failed');
}
if (strlen($tag) !== self::TAG_LEN) {
throw new RuntimeException('Unexpected tag length');
}
return base64_encode($salt . $nonce . $tag . $ciphertext);
}
public static function decryptFromBase64(string $blobB64, string $password): string
{
$blob = base64_decode($blobB64, true);
if ($blob === false) {
throw new InvalidArgumentException('Invalid base64');
}
$minLen = self::SALT_LEN + self::NONCE_LEN + self::TAG_LEN + 1;
if (strlen($blob) < $minLen) {
throw new InvalidArgumentException('Ciphertext blob too short');
}
$offset = 0;
$salt = substr($blob, $offset, self::SALT_LEN); $offset += self::SALT_LEN;
$nonce = substr($blob, $offset, self::NONCE_LEN); $offset += self::NONCE_LEN;
$tag = substr($blob, $offset, self::TAG_LEN); $offset += self::TAG_LEN;
$ciphertext = substr($blob, $offset);
// Critical: ensure tag length is exactly what you expect (PHP won't check)
if (strlen($tag) !== self::TAG_LEN) {
throw new InvalidArgumentException('Invalid tag length');
}
if (strlen($nonce) !== self::NONCE_LEN) {
throw new InvalidArgumentException('Invalid nonce length');
}
$key = self::deriveKey($password, $salt);
$plaintext = openssl_decrypt(
$ciphertext,
self::CIPHER,
$key,
OPENSSL_RAW_DATA,
$nonce,
$tag,
$aad = ''
);
if ($plaintext === false) {
throw new RuntimeException('Decryption or authentication failed');
}
return $plaintext;
}
}
Why this works:
random_bytes()provides a strong salt, and PBKDF2-HMAC-SHA256 slows brute-force attacks.- AES-256-GCM uses a securely generated 96-bit nonce to avoid reuse.
- Tag/nonce length validation ensures authentication fails on tampering.
Framework-Specific Guidance
Laravel - Session and CSRF Tokens
<?php
// Laravel automatically uses secure randomness for sessions and CSRF
// config/session.php
return [
'driver' => 'database', // Uses random_bytes internally
// ...
];
// Generate custom secure tokens in Laravel
use Illuminate\Support\Str;
// SECURE - Laravel's secure token generator
function generateVerificationToken() {
// Str::random() uses random_bytes internally
return Str::random(32);
}
// SECURE - Custom token with specific length
function generateAlphanumericToken() {
return Str::random(40); // 40 characters
}
// SECURE - UUID generation (uses random_bytes)
function generateUuid() {
return Str::uuid(); // UUID v4
}
// SECURE - Generate secure password reset token
use Illuminate\Support\Facades\Password;
function sendPasswordResetLink($email) {
// Laravel's Password::broker() uses random_bytes for tokens
Password::sendResetLink(['email' => $email]);
}
Why this works:
- Laravel uses OS-backed CSPRNG sources for sessions, CSRF, and token generation.
Str::random()andStr::uuid()use secure randomness under the hood.
Symfony - Session Management
<?php
use Symfony\Component\HttpFoundation\Session\Session;
use Symfony\Component\Security\Csrf\TokenGenerator\TokenGeneratorInterface;
// SECURE - Symfony session ID generation
// Uses random_bytes internally
$session = new Session();
$session->start();
// SECURE - Generate secure CSRF token
class SecureTokenGenerator implements TokenGeneratorInterface {
public function generateToken() {
return rtrim(strtr(base64_encode(random_bytes(32)), '+/', '-_'), '=');
}
}
// SECURE - Custom token generation
function generateSessionId() {
return bin2hex(random_bytes(32));
}
Why this works:
- Symfony session and CSRF components rely on secure random sources.
- Custom tokens use
random_bytes()directly for full entropy.
Custom API - API Key Generation and Management
<?php
declare(strict_types=1);
final class ApiKeyManager {
private const PREFIX = 'sk_live';
private const KEY_BYTES = 32; // 256 bits
private const KEY_CHARS = 43; // 32 bytes as unpadded base64url
private const CHECKSUM_CHARS = 6;
/**
* Generate secure API key with prefix and checksum
*/
public static function generateApiKey(): string {
$key = rtrim(strtr(base64_encode(random_bytes(self::KEY_BYTES)), '+/', '-_'), '=');
$checksum = substr(hash('sha256', $key), 0, self::CHECKSUM_CHARS);
return self::PREFIX . '_' . $key . '_' . $checksum;
}
/**
* Validate API key format before spending a database round trip.
*
* The key body is base64url, whose alphabet includes '_' and '-', and the
* prefix contains an underscore of its own - so the string cannot be split
* on '_'. Match the whole shape at fixed offsets instead.
*/
public static function validateFormat(string $apiKey): bool {
$pattern = '/\A' . preg_quote(self::PREFIX, '/')
. '_([A-Za-z0-9_-]{' . self::KEY_CHARS . '})'
. '_([0-9a-f]{' . self::CHECKSUM_CHARS . '})\z/';
if (preg_match($pattern, $apiKey, $m) !== 1) {
return false;
}
$expectedChecksum = substr(hash('sha256', $m[1]), 0, self::CHECKSUM_CHARS);
return hash_equals($expectedChecksum, $m[2]);
}
/**
* Hash API key for storage.
*
* A plain SHA-256, not password_hash(). Argon2id and bcrypt exist to make
* each guess expensive because a password has perhaps 30 bits behind it;
* this key has 256 from random_bytes(), so there is nothing to slow an
* attacker down for. What it would slow down is every authenticated
* request, since the key is verified on each one.
*/
public static function hashApiKey(string $apiKey): string {
return hash('sha256', $apiKey);
}
/**
* Verify API key against stored hash
*/
public static function verifyApiKey(string $apiKey, string $hash): bool {
return hash_equals($hash, hash('sha256', $apiKey));
}
}
// Usage
$apiKey = ApiKeyManager::generateApiKey();
echo "API Key: {$apiKey}\n";
// Shape: "sk_live_" + 43 base64url characters + "_" + 6 hex characters = 58 characters
// Validate format before database lookup
if (ApiKeyManager::validateFormat($apiKey)) {
$hash = ApiKeyManager::hashApiKey($apiKey);
// Store $hash in database, give $apiKey to user only once
}
Why this works:
- 32 bytes is 256 bits of entropy, which is the size this page uses for any long-lived credential. It renders as 43 base64url characters - the character count is not the bit count, and sizing by the string is how a key ends up at half the intended strength.
- The format check accepts every key the generator produces - measured over 50,000 keys, zero rejections. The obvious
explode('_', $apiKey)rejects all of them, because thesk_liveprefix carries an underscore itself and a three-part split therefore never matches; and even without that, a 43-character base64url body contains a_or a-about three quarters of the time. A validator that rejects everything passes the same "is a forged key refused?" test as one that works. \Aand\zanchor the whole string. PHP's$also matches immediately before a trailing newline, so...\zis what stops a key with\nappended from validating.- The stored value is a fast hash compared with
hash_equals(). Only the hash is stored, so a database leak does not yield usable keys.
Session Token Generation with Rotation
<?php
class SecureSessionManager {
/**
* Generate new session token
*/
public static function generateSessionToken() {
return bin2hex(random_bytes(32)); // 256 bits
}
/**
* Start secure session with custom token
*/
public static function startSession() {
// Set secure session parameters
ini_set('session.use_strict_mode', '1');
ini_set('session.cookie_httponly', '1');
ini_set('session.cookie_secure', '1'); // HTTPS only
ini_set('session.cookie_samesite', 'Strict');
session_start();
// Regenerate session ID periodically
if (!isset($_SESSION['created'])) {
$_SESSION['created'] = time();
} else if (time() - $_SESSION['created'] > 1800) {
// Regenerate every 30 minutes
session_regenerate_id(true);
$_SESSION['created'] = time();
}
}
/**
* Generate and store CSRF token
*/
public static function generateCsrfToken() {
$token = bin2hex(random_bytes(32));
$_SESSION['csrf_token'] = $token;
return $token;
}
/**
* Validate CSRF token
*/
public static function validateCsrfToken($token) {
if (!isset($_SESSION['csrf_token'])) {
return false;
}
// Use hash_equals for constant-time comparison
return hash_equals($_SESSION['csrf_token'], $token);
}
}
// Usage
SecureSessionManager::startSession();
$csrfToken = SecureSessionManager::generateCsrfToken();
// In form
echo '<input type="hidden" name="csrf_token" value="' . htmlspecialchars($csrfToken) . '">';
// On form submission
if (!SecureSessionManager::validateCsrfToken($_POST['csrf_token'] ?? '')) {
die('CSRF token validation failed');
}
Why this works:
- Session IDs and CSRF tokens use
random_bytes()for strong entropy. - Strict session settings and rotation reduce session fixation risk.
Considerations
Not every random value is a secret. The order of homepage banners, a
cache-busting suffix, a shuffled gallery - none of these gain an attacker
anything if guessed, and mt_rand() is the right function for them. The
question is not "is this random" but "does guessing it get someone something".
Session IDs, reset tokens, API keys, CSRF tokens, coupon codes with monetary
value, OTPs, salts and IVs all fail that test. If the value is not one of
those, record the finding as a false positive with the reason.
Length is a separate decision from algorithm. bin2hex(random_bytes(4))
comes from the OS CSPRNG and is still only 32 bits. Use at least 16 bytes for
tokens and 32 for key material, and remember bin2hex() doubles the string -
16 bytes renders as 32 characters, which is where 8 bytes gets mistaken for 16.
Base64url is the other trap in the opposite direction: 32 bytes renders as 43
characters, so a column sized at 32 truncates it and a reader counting
characters concludes the value is stronger than it is.
How many guesses does the attacker get? This is what decides whether a short value is a finding, and it is the question the bit count alone does not answer. An offline target - an encryption key, a signed cookie the attacker already holds, a token that appears in a URL they can read - is limited only by hardware, and 128 bits is the floor. An online target is limited by whatever sits in front of it. A six-digit code has under 20 bits, which is trivially enumerable in the abstract and genuinely safe behind five attempts and a ten-minute expiry, and genuinely an account takeover without them. So when the finding is on a short value, look for the attempt limit, the expiry and the single-use enforcement before deciding: if all three are present and enforced server-side, record it as a false positive with that reasoning; if any is missing, the cheapest fix is usually to add it rather than to lengthen the code, because a longer code that can be retried forever is still a code that can be retried forever.
Let random_bytes() fail. It throws when the system has no usable entropy
source rather than returning weak output. Wrapping it in a try that falls
back to mt_rand() converts a loud, rare failure into a silent, permanent one,
and that fallback is exactly the code path an attacker wants. If it throws, the
request should fail.
Check what the hosting environment actually provides. random_bytes() and
random_int() are core from PHP 7.0. On anything older the supported route is
the paragonie/random_compat polyfill, not a hand-rolled substitute - writing
your own CSPRNG shim is how this weakness gets reintroduced under a safer name.
Do not try to strengthen a weak value. Hashing with sha1() or md5(),
base64-encoding, or prefixing a strong value with uniqid() changes appearance
without adding entropy. A predictable prefix concatenated onto a random suffix
leaves you with the entropy of the suffix alone.
Common Pitfalls
- Correct generator, undersized output: Calling
random_bytes(4)for a session token because "the column is only 8 hex characters" - the generator is correct but 32 bits of entropy is far below the 128-bit minimum for a session token. Widen storage rather than shrinking the byte count. - Salting a password-derived key instead of deriving it properly: Generating the salt with
random_bytes()correctly, but deriving the encryption key with a single fast hash (hash('sha256', $password)) instead of a KDF like PBKDF2 or Argon2 - the salt's entropy doesn't help if the derivation step is fast enough to brute-force; the effective strength of the key stays bounded by the password's own entropy.
Additional Resources
- CWE-331: Insufficient Entropy
- NIST Randomness Recommendations
- OWASP Cryptographic Storage Cheat Sheet
- OWASP Password Storage Cheat Sheet - the source for the PBKDF2 iteration count, bcrypt work factor, and Argon2 parameters used here
- PHP password_hash() Documentation
- PHP random_bytes() Documentation
- PHP random_int() Documentation
- PHP Security Cheat Sheet