Randomness and Cryptography: CSPRNGs, Password Hashing, and Encryption Basics
Distinguish cryptographically secure randomness from ordinary randomness, generate secure tokens with random_bytes()/random_int(), hash and verify passwords correctly with password_hash()/password_verify(), and understand the core vocabulary of encryption -- keys, nonces/IVs, and authenticated encryption -- well enough to use established libraries safely.
Not all 'random' numbers are equally random. rand() and mt_rand() are fast and fine for shuffling a game board or picking a random quote of the day, but their output is predictable enough (given enough samples, or knowledge of the seed) that using them for anything security-sensitive -- a password reset token, a session ID, an API key -- can let an attacker guess or reconstruct values they were never supposed to see. PHP has a dedicated, cryptographically secure random number generator for exactly this purpose, plus a purpose-built password hashing API that handles the hard parts (salting, algorithm choice, iteration counts) for you. This lesson covers the vocabulary and the safe APIs: CSPRNGs, random_bytes()/random_int(), password_hash()/password_verify()/password_needs_rehash(), and the core ideas behind encryption (keys, nonces, authenticated encryption, signatures) that set up the next lesson on Sodium.
CSPRNGs: random_bytes() and random_int()
A cryptographically secure pseudorandom number generator (CSPRNG) produces output that is computationally infeasible to predict, even if an attacker knows some previous outputs or has significant computing power. This matters because ordinary PRNGs like mt_rand() are designed for statistical randomness (good distribution, useful for simulations and games), not unpredictability against an adversary -- their internal state can sometimes be reconstructed from observed outputs, which is disastrous if that state is what generated a password reset token.
PHP exposes a CSPRNG through two focused functions. random_bytes($length) returns a string of cryptographically secure random bytes, the right primitive for generating raw key material, salts, or tokens you'll encode yourself. random_int($min, $max) returns a cryptographically secure random integer in an inclusive range, the right primitive whenever you need a random *number* -- a 6-digit verification code, a random array index selection where security matters, or anything where mt_rand($min, $max) would otherwise be reached for.
Both functions are drop-in secure replacements for their insecure counterparts: random_int() has the exact same signature as mt_rand(), so migrating existing code is usually a one-line change. There is essentially never a legitimate reason to use rand(), mt_rand(), or uniqid() for anything that needs to resist a determined attacker's guessing.
• CSPRNG: random output that's infeasible to predict even with knowledge of prior outputs. • random_bytes($n): secure random byte string -- for keys, salts, raw token material. • random_int($min, $max): secure random integer -- a direct secure replacement for mt_rand(). • mt_rand()/rand()/uniqid() are unsuitable for anything security-sensitive; they are not CSPRNGs.
Generating Secure Tokens
A secure token (session ID, password reset token, API key, email-verification link) should be generated from random_bytes() and then encoded into a URL/storage-safe text form -- bin2hex() for a hex string, or base64_encode() (or the URL-safe variant) for a shorter representation. The security of the token comes entirely from the entropy of the underlying random bytes, not from the encoding, so the byte length matters: 16 bytes (128 bits) is a reasonable floor for most tokens, and 32 bytes (256 bits) is common for anything long-lived or high-value like an API key.
Tokens that grant access (password resets, magic login links) should also be time-limited and single-use: store an expiry timestamp alongside the token, and invalidate (delete or mark used) the token the moment it's successfully redeemed, so a leaked-but-already-used token grants nothing. Comparing a submitted token against the stored one should use a constant-time comparison (hash_equals()) rather than ==, since naive string comparison can leak timing information about how many leading bytes matched, which in principle lets a patient attacker narrow down a correct token byte by byte.
Never generate a token by hashing predictable input (like uniqid() or a timestamp) -- the security has to come from the randomness itself, not from feeding something guessable into a hash function afterward.
• Token = random_bytes(N) encoded as hex/base64; N=16-32 bytes is typical. • Store an expiry and mark tokens single-use for anything that grants access. • Compare submitted tokens with hash_equals(), never == or ===, to avoid timing side channels. • Never derive a token from predictable input like uniqid() or the current timestamp.
Password Hashing: password_hash(), password_verify(), and Rehashing
Passwords must never be stored in plaintext or reversibly encrypted -- they must be hashed with a slow, salted, purpose-built password hashing algorithm. password_hash($password, $algo) does all of this in one call: it generates a random salt automatically, applies the chosen algorithm with sensible default cost parameters, and returns a single self-describing string that embeds the algorithm, cost parameters, salt, and hash together -- so you never need a separate salt column.
PASSWORD_ARGON2ID (Argon2id) is the current recommended default: it's resistant to both GPU-cracking attacks (via its memory-hardness) and side-channel timing attacks (Argon2id specifically, as opposed to Argon2i or Argon2d alone). PASSWORD_BCRYPT is the older, still-reasonable default PHP used before Argon2 support landed, and PASSWORD_DEFAULT tracks whatever PHP currently considers best practice, which can change between PHP versions -- meaning hashes you generate today might use a stronger algorithm automatically after an upgrade, without you changing any code.
password_verify($password, $hash) checks a plaintext password against a stored hash using a constant-time comparison internally, so you should never manually re-hash and compare with ==. password_needs_rehash($hash, $algo, $options) checks whether an existing stored hash uses outdated parameters (a lower cost/memory setting than currently configured, or an older algorithm) -- the standard pattern is to call it right after a successful login and, if true, re-hash the just-verified plaintext password and update the stored hash, so your user base's password hashes gradually strengthen over time as you raise cost parameters, without forcing a mass password reset.
• password_hash() auto-generates a salt and returns one self-contained string (algorithm + cost + salt + hash). • PASSWORD_ARGON2ID is the current recommended algorithm; PASSWORD_BCRYPT remains acceptable. • password_verify() does constant-time comparison internally -- never hand-roll the comparison. • password_needs_rehash() + re-hash-on-login lets you raise cost parameters without a forced reset. • Never store passwords in plaintext, with a fast general-purpose hash (MD5/SHA-256 alone), or reversibly encrypted.
Encryption Vocabulary: Keys, Nonces, and Authenticated Encryption
A symmetric encryption key is a secret value used to both encrypt and decrypt data; anyone holding it can do both, so key distribution and storage (never in source control, ideally in a secrets manager or environment-injected secret) is often the hardest part of using encryption correctly, harder than the encryption algorithm itself. A nonce (or IV, initialization vector, depending on the algorithm) is a value that must be unique for every encryption operation performed with the same key -- reusing a nonce with the same key can catastrophically break the security of many encryption schemes, sometimes revealing the plaintext of both messages that shared it.
Plain encryption (confidentiality only) hides data's content but doesn't guarantee it wasn't tampered with in transit or storage -- an attacker who can't read the ciphertext might still be able to flip bits in it and produce a different, still-'successfully-decrypting' plaintext, depending on the mode. Authenticated encryption (AEAD -- Authenticated Encryption with Associated Data) adds a cryptographic integrity check, so decryption fails loudly if the ciphertext was altered in any way, rather than silently producing corrupted or attacker-controlled plaintext. Modern APIs (including Sodium, covered next) default to authenticated encryption specifically because plain, unauthenticated encryption is a well-known footgun.
A digital signature lets someone with a private key sign data so that anyone with the corresponding public key can verify the data came from that key holder and wasn't altered -- the inverse of public-key encryption's confidentiality property, providing authenticity and integrity instead. Key management -- generating keys with a real CSPRNG, storing private keys outside version control and outside publicly readable paths, rotating keys on a schedule or on suspected compromise, and never inventing your own encryption scheme -- matters more in practice than the choice of algorithm, since almost all real-world cryptography failures are implementation and key-handling mistakes, not broken math.
• Key: the secret controlling encryption/decryption; its storage and distribution are usually the hard part. • Nonce/IV: must never repeat for the same key -- reuse can catastrophically leak plaintext. • Authenticated encryption (AEAD) detects tampering; plain encryption alone does not. • Digital signatures provide authenticity/integrity via a private/public key pair, not confidentiality. • Never design your own cryptographic scheme -- use vetted libraries and established primitives (see the Sodium lesson).
The Core Safe-Randomness and Password APIs
random_bytes()/random_int() throw a Random\RandomException if the OS CSPRNG source is unavailable -- let it propagate, don't silently fall back to mt_rand(). password_hash() with no third argument uses default cost parameters for the chosen algorithm. password_needs_rehash() must be checked against the *currently configured* algorithm/options, not the hash's own embedded algorithm.
$tokenBytes = random_bytes(32);
$token = bin2hex($tokenBytes); // 64 hex chars, 256 bits of entropy
$code = random_int(100000, 999999); // secure 6-digit code
$hash = password_hash($plaintextPassword, PASSWORD_ARGON2ID);
if (password_verify($submittedPassword, $hash)) {
if (password_needs_rehash($hash, PASSWORD_ARGON2ID)) {
$hash = password_hash($submittedPassword, PASSWORD_ARGON2ID);
// save $hash back to storage
}
}Code Examples
The raw token is random_bytes()-derived and only ever sent to the user; the database stores a SHA-256 hash of it, so a database leak alone doesn't expose usable reset tokens. Expiry is checked before comparison, and hash_equals() avoids leaking timing information about a partial match.
$raw = bin2hex(random_bytes(32));
Common mistake: Storing the raw token directly in the database, or generating it with uniqid() -- both mean a database read or a predictable-input guess can produce a working reset token for any account.
Cost parameters live in one place (self::OPTIONS), so raising them later (e.g., bumping memory_cost as hardware gets faster) automatically triggers password_needs_rehash() to return true for existing users, and their hash silently strengthens the next time they log in successfully -- no forced password reset required. password_needs_rehash() only has the plaintext available inside a successful login flow, which is exactly when rehashing is safe -- you cannot rehash a password you don't have the plaintext for, which is also why password hashes are never meant to be reversible.
if (password_needs_rehash($storedHash, self::ALGO, self::OPTIONS)) { return $this->hash($plaintext); }
Both functions produce a 32-character-ish looking key, but only the random_bytes() version carries a cryptographic guarantee of unpredictability. mt_rand()-based generation has repeatedly been the root cause of real-world token-guessing vulnerabilities because its output can, in some configurations, be predicted from partial knowledge of prior outputs.
Common mistake: Reaching for mt_rand() or rand() because it 'looks random enough' in casual testing -- security-sensitive randomness needs a cryptographic guarantee, not a statistical one.
<?php
declare(strict_types=1);
final class PasswordResetToken
{
public function __construct(
public readonly string $token, // plaintext token given to the user (in the email link)
public readonly string $tokenHash, // what we actually store in the DB
public readonly DateTimeImmutable $expiresAt,
) {}
public static function issue(): self
{
$raw = bin2hex(random_bytes(32)); // 256 bits of entropy, 64 hex chars
return new self(
token: $raw,
tokenHash: hash('sha256', $raw), // store a hash, not the raw token, in the DB
expiresAt: new DateTimeImmutable('+30 minutes'),
);
}
}
function verifyResetToken(string $submittedToken, string $storedHash, DateTimeImmutable $storedExpiry): bool
{
if (new DateTimeImmutable() > $storedExpiry) {
return false; // expired -- reject even if the hash matches
}
$submittedHash = hash('sha256', $submittedToken);
return hash_equals($storedHash, $submittedHash); // constant-time comparison
}
$issued = PasswordResetToken::issue();
// Email $issued->token to the user; persist $issued->tokenHash and $issued->expiresAt.
// On red
…<?php
declare(strict_types=1);
final class PasswordService
{
private const ALGO = PASSWORD_ARGON2ID;
private const OPTIONS = ['memory_cost' => 1 << 17, 'time_cost' => 4, 'threads' => 2];
public function hash(string $plaintext): string
{
return password_hash($plaintext, self::ALGO, self::OPTIONS);
}
/** Returns a fresh hash to persist if rehashing occurred, or null if the caller should
* keep the existing stored hash unchanged. Throws if the password itself is wrong. */
public function verifyAndMaybeRehash(string $plaintext, string $storedHash): ?string
{
if (!password_verify($plaintext, $storedHash)) {
throw new InvalidArgumentException('Invalid credentials');
}
if (password_needs_rehash($storedHash, self::ALGO, self::OPTIONS)) {
return $this->hash($plaintext); // caller persists this over the old hash
}
return null; // hash is already up to date
}
}
$service = new PasswordService();
$storedHash = $service->hash('correct horse battery staple');
// Later, at login:
$fresh = $service->verifyAndMaybeRehash('correct horse battery staple', $storedHash);
if ($fresh !==
…<?php
declare(strict_types=1);
// WRONG: mt_rand()'s internal state is not designed to resist a determined attacker,
// and seeded/predictable variants exist across many PHP configurations.
function insecureApiKey(): string
{
$chars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
$key = '';
for ($i = 0; $i < 32; $i++) {
$key .= $chars[mt_rand(0, strlen($chars) - 1)];
}
return $key;
}
// RIGHT: random_bytes() draws from the OS's cryptographically secure source.
function secureApiKey(): string
{
return bin2hex(random_bytes(32)); // 256 bits of real entropy
}
echo secureApiKey() . "\n";Token Lifecycle: Issue, Store, Verify
The raw token never touches storage; only its hash does, and expiry is checked before any comparison happens.
random_bytes(32) --> raw token --sent to user (email/URL)--> [user]
|
v
hash(raw token) --stored in DB with expiry--> [database]
On redemption:
submitted token --hash--> compare with hash_equals() --> [database hash]
|
match + not expired --> accept, then delete/invalidateImportant Rules
Use random_bytes()/random_int() for anything security-sensitive; never rand()/mt_rand()/uniqid().
Never store passwords in plaintext, reversibly encrypted, or hashed with a fast general-purpose hash alone.
Always hash passwords with password_hash(); always verify with password_verify(), never manual comparison.
Call password_needs_rehash() after a successful login and update the stored hash when it returns true.
Compare tokens/hashes with hash_equals(), never ==/===, to avoid timing side-channel leaks.
Never reuse a nonce/IV with the same encryption key; never design a custom encryption scheme.
Why This Matters in Production
Predictable password-reset tokens and weakly hashed passwords are recurring root causes in real account-takeover incidents and credential-stuffing breaches -- attackers don't need to break the math of a hash function if the token generation or comparison logic has a shortcut they can exploit. password_needs_rehash() in particular is what lets a security team respond to 'GPUs got faster, our bcrypt cost is too low now' by raising a constant in one file, rather than needing to force every user to reset their password -- the fleet of stored hashes upgrades itself, one login at a time.
$token = md5(uniqid()); // 'random-looking' but derived from predictable, guessable input
$token = bin2hex(random_bytes(32)); // drawn directly from the OS CSPRNG uniqid() is based on the system clock (optionally with a small random suffix) and is not designed to resist guessing; hashing predictable input doesn't add real entropy, it just obscures the predictability slightly.
if ($submittedHash == $storedHash) { grantAccess(); } // plain string comparison of a token/hash if (hash_equals($storedHash, $submittedHash)) { grantAccess(); }
== short-circuits on the first differing byte, so comparison time can vary with how many leading bytes match -- an attacker measuring response times can, in principle, exploit that to guess a correct value byte by byte. hash_equals() runs in constant time regardless of where the strings first differ.
[multiple-choice] Why is mt_rand() unsuitable for generating a password reset token? A. It is too slow for high-traffic applications B. It is not a cryptographically secure generator -- its output can, in some configurations, be predicted from partial knowledge of prior outputs, unlike random_bytes() C. It can only generate integers, not strings D. It requires a database connection to seed properly
B. It is not a cryptographically secure generator -- its output can, in some configurations, be predicted from partial knowledge of prior outputs, unlike random_bytes() — mt_rand() is designed for statistical randomness (good distribution), not unpredictability against an adversary. random_bytes() draws from the OS's CSPRNG, which is specifically designed to resist prediction.
[multiple-choice] What does password_needs_rehash() let you do that password_hash()/password_verify() alone cannot? A. Recover a user's plaintext password from their stored hash B. Detect that an existing stored hash uses outdated cost parameters or algorithm, so you can transparently re-hash and upgrade it the next time that user logs in successfully C. Skip calling password_verify() entirely D. Hash a password without generating a salt
B. Detect that an existing stored hash uses outdated cost parameters or algorithm, so you can transparently re-hash and upgrade it the next time that user logs in successfully — password_needs_rehash() compares a stored hash's embedded parameters against your currently configured algorithm/options; combined with a successful login (where you briefly have the plaintext), it lets hashes strengthen gradually without a forced mass reset.
[multiple-choice] Why should token/hash comparisons use hash_equals() instead of == or ===? A. hash_equals() is faster B. == does not work on strings in PHP C. == can return early on the first mismatching byte, creating a timing side channel an attacker could exploit; hash_equals() runs in constant time D. hash_equals() automatically hashes both arguments first
C. == can return early on the first mismatching byte, creating a timing side channel an attacker could exploit; hash_equals() runs in constant time — Variable-time comparison can leak how many leading bytes matched via response timing, which in principle lets an attacker guess a correct value incrementally. hash_equals() is specifically designed to take the same amount of time regardless of where strings first differ.
Implement a function generateEmailVerificationLink(string $userId): array that returns ['url' => ..., 'tokenHash' => ..., 'expiresAt' => ...], where the token embedded in the URL has at least 128 bits of entropy, the value persisted for later verification is a hash (not the raw token), and the link expires in 24 hours.
function generateEmailVerificationLink(string $userId): array { $raw = bin2hex(random_bytes(16)); $hash = hash('sha256', $raw); $expiresAt = new DateTimeImmutable('+24 hours'); return ['url' => "https://example.com/verify?uid={$userId}&token={$raw}", 'tokenHash' => $hash, 'expiresAt' => $expiresAt]; } -- verification later re-hashes the submitted token and compares with hash_equals() against the stored $hash, rejecting if now() > expiresAt.
Use random_bytes(16) or more for the raw token, hash it for storage, and set expiresAt with DateTimeImmutable('+24 hours').