Current situation: PHP2Shell. Two critical PHP vulnerabilities (CVSS 9.2) allow malicious code without login. Patches since 21 October 2026.What to do now →

Secure Random Numbers and Tokens in PHP with random_bytes

How developers generate tokens for password links, API keys and one-time codes in PHP: cryptographically secure sources, sufficient length, storage as a hash and limited validity.

Last updated 6 min readSecure development

In short: In PHP you generate secure tokens with random_bytes(), for example 32 bytes that you convert to text with bin2hex() or Base64-URL. For random numbers in a range there is random_int(), and for codes from a fixed set of characters, Random\Randomizer::getBytesFromString() since PHP 8.3. You store tokens as a hash, limit their validity, use them only once and compare them with hash_equals().

"Forgot your password?" One click, and the system sends a link with a long code. Anyone who knows the link can set the password. That is exactly why this code must be unpredictable. If it is not, an attacker needs neither the mailbox nor the old password, only enough attempts.

In reviews of PHP applications, tokens built from the time of day, the user ID or a simple random number generator are found surprisingly often. They look long and random but are not. The same applies to API keys, invitation links, sharing links for documents, confirmation codes and CSRF tokens.

The risk is real: a predictable reset link allows the takeover of any account whose email address is known, without the owner noticing anything until they can no longer log in themselves.

Since version 7.0, PHP offers a simple and secure solution. It just has to be used consistently.

random_bytes in PHP: Where Secure Random Values Come From

According to the PHP manual, random_bytes() delivers cryptographically secure random bytes that are also suitable for long-lived secrets such as keys. The function uses the operating system's source of randomness, for example getrandom() on Linux and the CNG interface on Windows. If it finds no suitable source, it throws a Random\RandomException since PHP 8.2.

The overview shows which functions are suitable for secrets:

Function Suitable for tokens? Note
random_bytes() yes secure bytes, since PHP 7.0
random_int() yes secure number in a range, since PHP 7.0
Random\Randomizer with Secure engine yes Secure is the default engine according to the manual
rand(), mt_rand() no predictable, only for non-security purposes
uniqid() no time-based, intended for uniqueness
md5(time()), sha1($id) no input guessable, the hash changes nothing about that
str_shuffle(), array_rand() no do not use a secure generator

Generating Tokens: Length and Encoding

32 random bytes correspond to 256 bits and are more than enough for almost any purpose. For transmission in links or headers, the token has to be converted to text. Two variants are common:

<?php
declare(strict_types=1);

// Hex: 64 characters, only 0-9 and a-f
$tokenHex = bin2hex(random_bytes(32));

// Base64-URL: 43 characters, safe in URLs without further encoding
$tokenUrl = rtrim(strtr(base64_encode(random_bytes(32)), '+/', '-_'), '=');

// Random number in a range, for example for a selection
$index = random_int(0, 9);

// From PHP 8.3: six-digit numeric code, e.g. for confirmation by SMS
$randomizer = new \Random\Randomizer();
$code = $randomizer->getBytesFromString('0123456789', 6);

Short numeric codes are justifiable only if the number of attempts is strictly limited and the code expires after a few minutes. For links sent by email, always use the long token.

For API keys that partners or your own systems use permanently, two additions have proven worthwhile. A short, non-secret prefix before the random part, for example for environment or purpose, makes it easier to recognize in logs and when found in code repositories. And the key is shown only once at creation; afterwards the system knows only its hash. If a key is lost, it is replaced, not recovered.

Be careful with home-built tokens that contain information, such as user ID and expiry time in Base64. Encoding is not protection: anyone can read and change such tokens. If a token is to carry data, it must be signed with a secret key, for example with hash_hmac('sha256', ...), and the signature is verified with hash_equals(). For more elaborate cases, reviewed libraries are preferable to your own constructions.

Storing, Verifying and Expiring Tokens

A securely generated token is only half the battle. At least as important is how the application handles it. A proven approach for reset and invitation links:

  1. Store only the hash: The database holds hash('sha256', $token); the token itself goes to the user only via the link.
  2. Limit validity: Store the expiry time as well and check it on redemption.
  3. Use once: After successful use, delete the entry or mark it as used.
  4. Invalidate on changes: After a password change, delete all open reset tokens of the account.
  5. Compare in constant time: Where a token is compared directly with a stored value, use hash_equals(), with the stored value as the first parameter.
<?php
// Generate
$token = bin2hex(random_bytes(32));
$eintrag = [
    'konto_id'   => $kontoId,
    'token_hash' => hash('sha256', $token),
    'gueltig_bis'=> (new DateTimeImmutable('+30 minutes'))->format('Y-m-d H:i:s'),
];
// send $token only in the link, store $eintrag

// Redeem
$gesucht = hash('sha256', (string) ($_GET['token'] ?? ''));
// look up the entry by token_hash via prepared statement,
// check expiry, then delete the entry immediately

Also limit the number of reset requests per account and time period so that the function cannot be abused for mass sending of emails. The response to a reset request should also not reveal whether the given address has an account at all.

A fast hash such as SHA-256 is enough here because the token itself is already long and random. This does not apply to passwords that people choose; those need slow algorithms, as Storing Passwords Securely in PHP explains.

Tip

Tokens do not belong in log files. Check whether your web server logs complete URLs with query parameters, and whether error reports send request data along.

After an Incident: Renewing Tokens and Keys

Anyone who could execute code on a server usually also had access to stored tokens, API keys and session data. If, after PHP2Shell or another incident, there is a suspicion of a break-in, you should therefore renew not only passwords but also long-lived tokens and keys: API access, signing keys for cookies or links, access to payment and shipping services.

Sessions are a special case: PHP generates session IDs itself. How to secure their settings and end sessions after an incident is described in the guide Securing Sessions in PHP. For form tokens against forged requests, see CSRF Protection in PHP Applications. Further built-in tools are presented in Built-in Security Functions of PHP.

How does PHP SOS help?

  • We find all places in your application where tokens, codes and keys are generated and replace insecure sources.
  • We check storage, expiry and single use of reset, invitation and sharing links.
  • We make sure tokens do not end up in logs, error reports or analytics tools.
  • After an incident, we plan the orderly replacement of tokens and keys with you.

We offer the review of your own PHP software as a security audit and penetration test.

Frequently asked questions

Why is mt_rand unsuitable for tokens?

mt_rand and rand are intended for statistics and games, not for secrets. Their outputs can under some circumstances be predicted. For anything an attacker must not be able to guess, use random_bytes or random_int.

How long should a token be?

For password reset links, invitations or API keys, 32 random bytes are a solid choice, 64 characters as hex text. 16 bytes, that is 128 bits, is a common lower limit.

Is uniqid a secure token?

No. uniqid is based on the current time and is intended for uniqueness, not secrecy. Combinations such as md5 of the time or user ID are also predictable.

Do I have to encrypt reset tokens in the database?

Store a hash of the token instead, for example with SHA-256. Anyone who reads the database cannot build valid links from it. Because the token itself is already long and random, it does not need a slow algorithm as passwords do.

What happens if the system finds no source of randomness?

Since PHP 8.2, random_bytes and random_int throw a Random\RandomException. Catch this case and abort the operation instead of falling back to a weaker source.

Sources

Immediate contact

Every hour counts. Write to us now.

Briefly describe your system, PHP version and observation. We will get back to you, clarify the situation and work remotely until the incident is closed and documented.

Immediate help