CWE-330: Use of Insufficiently Random Values - PHP
Overview
Weak random number generation in PHP occurs when developers use insecure functions like rand(), mt_rand(), or uniqid() for security-sensitive operations such as generating session tokens, password reset tokens, API keys, CSRF tokens, or cryptographic keys. These functions are deterministic pseudo-random number generators (PRNGs) whose output an attacker can predict. For security purposes, PHP provides random_bytes() and random_int() (PHP 7.0+), which use cryptographically secure random number generators (CSPRNGs) sourced from the operating system.
Primary Defence: Use random_bytes() and random_int() (PHP 7.0+) for all security-sensitive random value generation including tokens and keys.
Common Vulnerable Patterns
rand() for Session IDs
<?php
// VULNERABLE - Predictable session ID
function generateSessionId() {
$sessionId = rand(1000000, 9999999);
return $sessionId;
}
// Attacker can predict: If they observe a few session IDs,
// they can infer patterns and predict future values
// rand() is not a cryptographic RNG
?>
Why this is vulnerable: Historically, rand() varied by platform and was not cryptographically secure; since PHP 7.1 it is an alias of mt_rand(), which uses the MT19937 algorithm and is still not cryptographically secure.
mt_rand() for Password Reset Tokens
<?php
// VULNERABLE - Predictable reset token
function generateResetToken() {
$chars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
$token = '';
for ($i = 0; $i < 32; $i++) {
$token .= $chars[mt_rand(0, strlen($chars) - 1)];
}
return $token;
}
// Token looks random: "a7B3xQ9..." but is predictable
// Attacker can predict based on mt_rand() seed
?>
Why this is vulnerable: mt_rand() uses the Mersenne Twister algorithm, which is not cryptographically secure: its internal state can be reconstructed from the values it has already returned, and the rest of the sequence predicted.
uniqid() for Security Tokens
<?php
// VULNERABLE - Predictable token
function generateToken() {
// uniqid() is based on current time in microseconds
return uniqid('token_', true);
}
// Output: "token_6398f8a3b5c7e1.23456789"
// Attacker knowing approximate time can predict or brute force
?>
Why this is vulnerable: uniqid() is based on the current time, so it is predictable and unsuitable for security purposes.
Time-based Seed
<?php
// VULNERABLE - Predictable seed
mt_srand(time()); // Seed with current timestamp
$token = mt_rand(0, 999999);
// Attacker knowing approximate time can brute force seed
// Time has limited entropy (~32 bits for timestamp)
?>
Why this is vulnerable: Time is predictable. Attackers can enumerate all possible timestamps within a time window and reproduce tokens.
openssl_random_pseudo_bytes() Kept for the Strength Flag
<?php
// STALE, not weak - the flag this code exists to check can no longer be false
$token = bin2hex(openssl_random_pseudo_bytes(16, $crypto_strong));
if (!$crypto_strong) {
// Dead branch on any supported PHP: see below
$token = md5(uniqid((string) mt_rand(), true));
}
?>
Why this is vulnerable: Not for the reason the code assumes, and the mismatch is the danger. openssl_random_pseudo_bytes() could return weak bytes on PHP 5.6 and earlier, which is where the by-reference $crypto_strong parameter comes from and why so much code still tests it. It cannot now: in php-src the flag is assigned true on every path that returns a value and false only when the function has already thrown, and since PHP 7.4 failure throws rather than returning false. So the if is unreachable - and it is the fallback inside it that is the weakness, sitting in the codebase looking like defensive programming. Delete the branch and use random_bytes(), which needs no flag because it has no weak mode to fall back to. Treat a finding on openssl_random_pseudo_bytes() itself as a modernisation, not an incident; the tokens it produced are sound.
array_rand() for Security
<?php
// VULNERABLE - Using array_rand for security purposes
function generateVerificationCode() {
$digits = range(0, 9);
$code = '';
for ($i = 0; $i < 6; $i++) {
$key = array_rand($digits);
$code .= $digits[$key];
}
return $code;
}
// array_rand uses mt_rand internally - predictable
?>
Why this is vulnerable: array_rand() uses mt_rand() internally, making selections predictable.
str_shuffle() for Security
<?php
// VULNERABLE - Shuffling for security purposes
function generateCode($userId) {
$chars = str_repeat('0123456789', 10);
$shuffled = str_shuffle($chars);
return substr($shuffled, 0, 6);
}
// str_shuffle draws from the same Mt19937 engine as mt_rand()
?>
Why this is vulnerable: str_shuffle(), shuffle() and array_rand() all draw from the global Mt19937 engine - the same one mt_rand() uses, and the same one rand() has aliased to since PHP 7.1. Verified on PHP 8.5.8: mt_srand(7) followed by str_shuffle('0123456789') returned 9083267145 on both of two runs, and array_rand() repeated likewise. The shuffle adds no unpredictability of its own; it rearranges a known alphabet using a sequence an attacker can reconstruct. Note the second, independent problem here: taking 6 characters off a shuffled str_repeat('0123456789', 10) is a 6-digit code however it was shuffled.
Predictable Encryption Key
<?php
// VULNERABLE - Key derived from weak random
function generateEncryptionKey() {
$key = '';
for ($i = 0; $i < 32; $i++) {
$key .= chr(mt_rand(0, 255));
}
return $key;
}
// Attacker can predict key if they know seed
?>
Why this is vulnerable: Encryption keys must have full entropy. Predictable random = predictable keys = broken encryption.
Weak Salt for Passwords
<?php
// VULNERABLE - Weak salt
function hashPassword($password) {
$salt = mt_rand(0, 999999); // Predictable salt
$salted = $salt . $password;
$hashed = hash('sha256', $salted);
return [$hashed, $salt];
}
// Salt must be unpredictable to prevent rainbow tables
?>
Why this is vulnerable: Predictable salts can be precomputed in rainbow tables, defeating the purpose of salting.
Secure Patterns
random_bytes() for Session IDs
<?php
// SECURE - Cryptographically strong session ID
function generateSessionId() {
// 16 bytes = 128 bits of entropy
$randomBytes = random_bytes(16);
$sessionId = bin2hex($randomBytes);
return $sessionId;
}
// Example: "3a7b9c8e4f1d2a5b6c7e8f9a0b1c2d3e"
// Unpredictable even with knowledge of previous tokens
?>
Why this works:
random_bytes()uses OS CSPRNG: Provides cryptographically strong randomness from platform random APIs such asgetrandom()//dev/urandomon Unix-like systems and CNG on Windows- 128 bits makes collisions negligible: 2^-128 is the chance that two particular IDs match, which is not the figure that matters. The one that does is the birthday bound: with n IDs the chance any two collide is roughly n^2/2^129, so a billion IDs (about 2^30) gives about 2^-69, and a collision only becomes likely around 2^64 IDs. The conclusion is the same - negligible - but the rule to carry is that collision resistance is half the bit length, not all of it
- Unpredictable output: Unlike
rand()/mt_rand(), observing IDs provides no prediction capability - Prevents session guessing: Cryptographic randomness stops an attacker guessing or brute-forcing a live session ID. It does not address session fixation, where the attacker supplies an ID the application then keeps - that one is closed by regenerating the ID when privilege changes
- Fail-safe behavior: Throws exception if secure randomness unavailable, never falls back to weak sources
random_bytes() with base64 for Reset Tokens
<?php
// SECURE - URL-safe reset token
function generateResetToken() {
// 32 bytes = 256 bits of entropy
$randomBytes = random_bytes(32);
// URL-safe base64 encoding
$token = rtrim(strtr(base64_encode($randomBytes), '+/', '-_'), '=');
return $token;
}
// Example: "A3b7K9xQmZpLr4tYwFj2nVc8hG1sE6uD..."
// Can be safely used in URLs, emails
?>
Why this works:
- 256 bits prevents brute-force: Testing 1 trillion tokens/second takes ~10^57 years to exhaust half the space
- URL-safe base64 encoding: Replaces
+with-,/with_, removes=for safe transmission in URLs/emails - Independent token generation: Observing millions of tokens provides no prediction capability
- Not derived from time or a counter: Unlike timestamp/sequential tokens, which can be predicted or enumerated
- Best practices: Single-use, 1-24 hour expiration, rate limiting, store hashed in database
random_int() for Random Integers
<?php
// SECURE - Random integer in range
function generateVerificationCode() {
// 6-digit code: 000000 to 999999
$code = random_int(0, 999999);
return str_pad($code, 6, '0', STR_PAD_LEFT);
}
// Example: "047382"
// Uniformly distributed, unpredictable
?>
Why this works:
- Rejection sampling ensures uniform distribution: Every code from 000000 to 999999 is equally likely, unlike naive modulo which introduces bias when the range doesn't divide evenly into 256
- ~20 bits entropy suitable for short-lived verification: Combined with rate limiting (3-5 attempts), expiration (5-15 minutes), account lockout
- Cryptographically unpredictable: Knowledge of previous codes provides no prediction capability
str_pad()prevents information leakage: Always 6 digits, no magnitude hints
Password Hashing with password_hash()
<?php
// SECURE - Proper password hashing
function hashPassword($password) {
// password_hash handles salt generation internally with CSPRNG
// Uses bcrypt by default (PASSWORD_DEFAULT)
$hashed = password_hash($password, PASSWORD_DEFAULT);
return $hashed;
}
function verifyPassword($password, $hash) {
// Constant-time comparison to prevent timing attacks
return password_verify($password, $hash);
}
// Or explicitly use argon2id (recommended)
function hashPasswordArgon2($password) {
$hashed = password_hash($password, PASSWORD_ARGON2ID);
return $hashed;
}
?>
Why this works:
password_hash()auto-generates cryptographic salts: Uses system CSPRNG, embeds salt in output hash- Bcrypt (
PASSWORD_DEFAULT) is computationally expensive: Cost factor (default 10) slows brute-force attacks - Argon2id (
PASSWORD_ARGON2ID) is memory-hard: 2015 Password Hashing Competition winner, resists GPU/ASIC attacks - Argon2id combines the two variants: Argon2i's side-channel resistance + Argon2d's time-memory trade-off resistance
password_verify()prevents timing attacks: Constant-time comparison blocks response-time analysis
Cryptographic Key Generation
<?php
// SECURE - Generate encryption key
function generateEncryptionKey($length = 32) {
// 32 bytes = 256 bits for AES-256
$key = random_bytes($length);
return $key;
}
// For display/storage as hex
function generateEncryptionKeyHex($length = 32) {
$key = random_bytes($length);
return bin2hex($key);
}
// For base64 storage
function generateEncryptionKeyBase64($length = 32) {
$key = random_bytes($length);
return base64_encode($key);
}
?>
Why this works:
- Keys come straight from the OS CSPRNG: It combines hardware entropy from CPU jitter, interrupt timing and any hardware RNG
- 256-bit AES keys resist brute-force: Grover's algorithm leaves 128-bit post-quantum security
- Raw bytes for direct use: Compatible with
openssl_encrypt()and other crypto functions - Hex/base64 variants for storage: Suitable for databases or configuration files
- Keys derived from a password need a KDF: PBKDF2, Argon2 or bcrypt; never
mt_rand()
Secure Random String Generation
<?php
// SECURE - Random password generation
function generatePassword($length = 16) {
$chars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!@#$%^&*()';
$charsLength = strlen($chars);
$password = '';
for ($i = 0; $i < $length; $i++) {
// Use random_int for secure index selection
$password .= $chars[random_int(0, $charsLength - 1)];
}
return $password;
}
// Example: "x7!aB9#qZ3$mK2&p"
// Each character independently random
?>
Why this works:
- Cryptographically secure character selection: Each selection independent and uniformly distributed
- 72 characters over 16 positions = ~98 bits entropy: 72^16 ≈ 5.2 x 10^29 possibilities. Count the alphabet rather than estimating it - this one is 26 + 26 + 10 + 10
- Independent generations: Knowing previous passwords provides no prediction advantage
- No recoverable state: Unlike Mersenne Twister, the generator's internal state cannot be reconstructed from its outputs
- Uniform distribution critical: Biased selection reduces entropy and aids dictionary attacks
- Character set best practices: Include upper/lower/digits/special; optionally filter ambiguous chars (O/0/l/1/I)
CSRF Token Generation
<?php
// SECURE - CSRF token generation and validation
function generateCsrfToken() {
// Generate 32 bytes = 256 bits
$token = bin2hex(random_bytes(32));
// Store in session
$_SESSION['csrf_token'] = $token;
return $token;
}
function validateCsrfToken($token) {
if (!isset($_SESSION['csrf_token'])) {
return false;
}
// Use hash_equals for constant-time comparison
return hash_equals($_SESSION['csrf_token'], $token);
}
?>
Why this works:
- 256 bits prevents guessing/brute-force: Computationally infeasible for attackers to predict valid tokens
- Server-side session storage: Token must match submitted value for validation
hash_equals()prevents timing attacks: where both values are the same length it examines every byte, so the time taken does not depend on how much of the submitted token matched. A length mismatch returnsfalseimmediately, so the token's length has to come from the fixed format above rather than being treated as a secret- Standard operators leak timing:
==and===return at the first difference, so the duration reflects how much matched. The signal is coarser than it sounds - the comparison runs a machine word at a time - but the length difference leaks cleanly and the fix costs one call. See CWE-208 - Regenerate after auth changes: Create new tokens on login/logout
- Validate state-changing operations: All POST/PUT/DELETE requests must include valid token
API Key Generation
<?php
// SECURE - API key with prefix and checksum
function generateApiKey() {
// Generate 24 bytes of random data
$randomBytes = random_bytes(24);
// Convert to base64url encoding
$key = rtrim(strtr(base64_encode($randomBytes), '+/', '-_'), '=');
// Add prefix for identification
$prefix = 'sk_live_';
// Add checksum (optional but recommended)
$checksum = substr(hash('sha256', $key), 0, 6);
return $prefix . $key . '_' . $checksum;
}
// Example: "sk_live_A3b7K9xQmZpLr4tYwFj2nVc8hG1sE6uD_3f8a2e"
?>
Why this works:
- 192 bits of entropy: 24 bytes is beyond brute-force reach for an authentication credential
- base64url encoding is URL-safe: Easy to copy-paste, suitable for headers and query parameters
- Prefix identifies key type:
sk_live_enables log recognition, prevents accidental exposure via code scanning - Checksum enables quick validation: Detect typos and truncation before the database lookup, reducing unnecessary queries. It is an unkeyed SHA-256 prefix over a value the holder already has, so anyone can compute it: it proves the key was copied intact and proves nothing about who issued it. If you need the key to be unforgeable without a lookup, the checksum has to be an HMAC under a server-side secret - and at that point you are building a token format, not an API key
- Best practices: Store keys hashed, implement rotation, rate limiting, audit logging. Use a plain
hash('sha256', $key)rather thanpassword_hash()here:password_hash()embeds a fresh random salt in every hash, so the same key hashes differently each time and cannot be looked up by its hash - and its deliberate slowness would be paid on every API request. The slow, salted algorithms exist for low-entropy secrets; a 192-bit random key is not one
UUID Generation
<?php
// SECURE - Generate UUID v4
function generateUuid() {
// Generate 16 random bytes
$data = random_bytes(16);
// Set version to 0100 (UUID v4)
$data[6] = chr(ord($data[6]) & 0x0f | 0x40);
// Set variant to 10
$data[8] = chr(ord($data[8]) & 0x3f | 0x80);
// Format as UUID string
return vsprintf('%s%s-%s-%s-%s-%s%s%s', str_split(bin2hex($data), 4));
}
// Or use Ramsey UUID library
// composer require ramsey/uuid
use Ramsey\Uuid\Uuid;
function generateUuidLibrary() {
return Uuid::uuid4()->toString();
}
// Example: "f47ac10b-58cc-4372-a567-0e02b2c3d479"
?>
Why this works:
- 122 bits randomness (RFC 4122 v4): 6 bits reserved for version/variant identifiers
- Negligible collision probability: at 1 billion UUIDs/second a 50% chance of a single collision arrives after about 86 years - the 2.7 x 10^18 UUIDs that takes is 2.7 x 10^9 seconds, not years
- Ideal for distributed systems: No coordination needed for unique ID generation
- Prevents information leakage: Unlike sequential IDs, doesn't reveal record counts or creation order
- Ramsey UUID library recommended: Well-tested, handles edge cases, supports v1/v3/v5/v6/v7
- Binary storage optimization: 16 bytes vs 36 characters saves space and improves index performance
Considerations
Ask what guessing the value would get someone. Randomness has non-security
uses everywhere - sampling, shuffling a result set, jitter, cache-busting, test
fixtures - and none of them need a CSPRNG. The finding is material when the
value is a session identifier, a token, a key, an OTP, a salt, an IV, or
anything else whose unpredictability is what makes it work. If it is not,
mt_rand() is the correct choice and the finding should be closed with the
reason recorded.
Do not blanket-replace. random_int() goes to the OS for every call and is
meaningfully slower than mt_rand(). That cost is irrelevant for a handful of
tokens per request and very relevant in a loop generating millions of values.
Replacing every call site to make a scanner quiet trades real throughput for no
security benefit, and it makes the genuine findings harder to see.
PHP has no built-in UUID function. Whatever a codebase calls uuid() was
written by someone, and the question is what it was written on top of - the
random_bytes() construction above and ramsey/uuid's Uuid::uuid4() are
sound; a hand-rolled one over mt_rand() or uniqid() is this weakness with a
reassuring name. Check the version too: Uuid::uuid1() embeds a timestamp and a
MAC address and Uuid::uuid7() embeds a timestamp, so neither is unpredictable
and neither belongs in a token, however many bits it prints.
Anything derived from a weak value stays weak. md5(uniqid()),
sha1(microtime()) and hash('sha256', mt_rand()) all appear in this codebase's
ancestors and all of them are exactly as guessable as their input - hashing
spreads entropy out, it does not create any. There is no post-processing that
fixes the source; only replacing the generator does.
Check the length once the generator is right. This CWE is about the
unpredictability of the value, which depends on both the source and how much of
it you take. Four bytes from random_bytes() is still only 32 bits. Use at
least 16 bytes for tokens and 32 for key material, and remember bin2hex()
doubles the character count, which is where half the intended entropy usually
goes missing - a 32-character hex token carries 128 bits, not 256.
Common Pitfalls
- Reducing CSPRNG bytes through a biased custom alphabet: Calling
random_bytes()correctly, then mapping each byte into a short custom character set with$chars[$byte % strlen($chars)]- whenstrlen($chars)doesn't evenly divide 256, the low end of the alphabet comes up more often, biasing the output even though the underlying bytes were cryptographically random. - Falling back to
mt_rand()whenrandom_bytes()throws:random_bytes()/random_int()throw anExceptionif secure randomness is unavailable. Catching that exception and falling back tomt_rand()"so the request doesn't fail" silently reintroduces the predictable generator in exactly the failure scenario (resource exhaustion, misconfiguration) where it matters most - and the fallback is easy to miss in review since the primary path looks correct.
Key Security Functions
Token Generation Helper
<?php
/**
* Generate secure tokens for various purposes
*
* @param string $purpose Purpose identifier: 'session', 'reset', 'api', 'csrf'
* @param int $bytes Number of random bytes (default 32 = 256 bits)
* @return string Secure random token as hex string
*/
function generateToken($purpose, $bytes = 32) {
$token = bin2hex(random_bytes($bytes));
// Optionally prefix with purpose for identification
return $purpose . '_' . $token;
}
// Usage
$sessionToken = generateToken('session'); // "session_a7b3c9..."
$resetToken = generateToken('reset', 48); // Longer for password reset
?>
Secure Random String Generator
<?php
/**
* Generate cryptographically secure random string
*
* @param int $length Desired string length
* @param string $charset Character set: 'alphanumeric', 'hex', 'ascii', 'digits'
* @return string Random string
*/
function generateSecureString($length, $charset = 'alphanumeric') {
$charsets = [
'alphanumeric' => 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789',
'hex' => '0123456789abcdef',
'ascii' => 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!@#$%^&*()',
'digits' => '0123456789',
];
$chars = $charsets[$charset] ?? $charsets['alphanumeric'];
$charsLength = strlen($chars);
$result = '';
for ($i = 0; $i < $length; $i++) {
$result .= $chars[random_int(0, $charsLength - 1)];
}
return $result;
}
// Usage
$apiKey = generateSecureString(32, 'alphanumeric');
$pin = generateSecureString(6, 'digits');
?>
Entropy Checker
<?php
/**
* Entropy of a token, in bits.
*
* Count the BYTES DRAWN, never the characters printed: encoding
* rearranges entropy, it never adds any.
*
* @param int $randomByteCount Bytes taken from the CSPRNG
* @return int Entropy in bits
*/
function entropyBits(int $randomByteCount): int {
return $randomByteCount * 8;
}
// Usage
$nbytes = 32;
$token = bin2hex(random_bytes($nbytes));
echo strlen($token), "\n"; // 64 characters
echo entropyBits($nbytes), "\n"; // 256 bits - the correct answer
// Minimum entropy recommendations:
// - Session tokens: 128 bits
// - Password reset: 128-256 bits
// - API keys: 128-256 bits
// - Encryption keys: 128-256 bits (AES-128/AES-256)
?>
The obvious version of this helper takes the finished string and returns
strlen($value) * log($alphabetSize, 2). For hex that is right - 64 characters
times 4 is 256 - and for base64 it is not. base64_encode(random_bytes(32)) is
44 characters, and 44 x 6 = 264, eight bits more than the generator produced:
the final data character carries only four real bits rather than six, and the
= is padding that carries none at all. An entropy check that overstates entropy
is worse than none, because the value it gets run on is the borderline one.
Count the bytes you asked random_bytes() for.
Secure Random Bytes with Fallback
<?php
/**
* Generate cryptographically secure random bytes with error handling
*
* @param int $length Number of bytes to generate
* @return string Random bytes
* @throws Exception If cannot generate secure random bytes
*/
function secureRandomBytes($length) {
try {
// PHP 7.0+ random_bytes
return random_bytes($length);
} catch (Exception $e) {
// This should never happen on properly configured systems
// Log the error and fail securely
error_log("CRITICAL: Cannot generate secure random bytes: " . $e->getMessage());
throw new Exception("Failed to generate secure random bytes");
}
}
// Usage
try {
$token = bin2hex(secureRandomBytes(32));
} catch (Exception $e) {
// Handle error - do not fall back to weak random
die("Security error: " . $e->getMessage());
}
?>