Secure Random Tokens: random vs secrets, Measured
We rebuilt Python's random from 624 outputs and predicted the next session tokens, then measured secrets, random_bytes, UUIDv4 and time-seeded tokens.
Series: Security Basics lecture 3/6 (intermediate). For developers who already hash passwords properly (lecture 2) and now need to hand out session IDs, password reset links, API keys or invite codes. By the end you will know which random function to call in Python and PHP, how long a token must be, and why "it looks random" proves nothing.
If your app emails a password reset link, the token in that link is the password for the next 15 minutes. The OWASP Top 10 (2021 edition) files weak token generation under A02 Cryptographic Failures and A07 Identification and Authentication Failures, and the root cause is almost always the same: a general-purpose random number generator was used where a cryptographic one was needed. Below, we break a toy app of our own that does exactly that, measure every alternative, and end with the rule. Everything ran on Python 3.9.6, PHP 8.5.6 and OpenSSL 4.1.0 on an Apple Silicon Mac (M1 Pro), with fixed seeds where seeds matter. All attacks target only code and data created on that machine.
If "hash" and "MAC" are still blurry, read lecture 1 first. This series is part of the programming learning roadmap.
random vs secrets: two different jobs
Python ships two random APIs that look alike and are not:
| Module | Algorithm | Seeded from | Meant for |
|---|---|---|---|
random |
Mersenne Twister (MT19937), 624 × 32-bit words of state | os.urandom when you call random.seed() with no argument, or whatever you pass |
simulations, games, shuffling test data, reproducible runs |
secrets (3.6+) |
no own algorithm: reads the OS CSPRNG | the kernel | tokens, keys, salts, reset codes, anything an attacker benefits from guessing |
A CSPRNG (cryptographically secure pseudo-random number generator) has one extra promise: seeing any amount of its output does not help you predict the next output. MT19937 makes no such promise. It is excellent at being statistically random and terrible at being unpredictable.
import random, secrets
print(secrets.token_hex(16)) # 32 hex chars = 128 bits
print(secrets.token_urlsafe(16)) # ~22 URL-safe chars = 128 bits
print(secrets.DEFAULT_ENTROPY) # bytes used when you pass no length
try:
random.SystemRandom().getstate()
except Exception as e:
print(type(e).__name__, e)
e6a825116541f1d1a20030e60398aa48
CBomwXz1dT6i6kcAXQqszA
32
NotImplementedError System entropy source does not have state.
That last line is the whole point of a CSPRNG in one error message: there is no state in the process for anyone to copy.
Common mistake: tokens from random → a clone predicts them → fix
Here is the mistake in its most common shape. A toy app uses one random.Random for two things: public request IDs (8 hex characters shown in an API response, harmless on their own) and secret session tokens. An observer who collects 624 consecutive request IDs can rebuild the generator.
How? MT19937 keeps 624 words of state. Each 32-bit output is one state word passed through a "tempering" function: four shift-and-XOR steps. Every step is reversible, so each output gives back one state word exactly. After 624 outputs you hold the entire state, and Python's own setstate() loads it into a clone.
# mt_predict.py - LOCAL demo: recover Python's random (MT19937) state
# from 624 consecutive 32-bit outputs, then predict the next "tokens".
import random
def unshift_right(y, shift):
result = y
for _ in range(32 // shift + 1):
result = y ^ (result >> shift)
return result & 0xFFFFFFFF
def unshift_left(y, shift, mask):
result = y
for _ in range(32 // shift + 1):
result = y ^ ((result << shift) & mask)
return result & 0xFFFFFFFF
def untemper(y): # undo the 4 tempering steps, in reverse
y = unshift_right(y, 18)
y = unshift_left(y, 15, 0xEFC60000)
y = unshift_left(y, 7, 0x9D2C5680)
y = unshift_right(y, 11)
return y
# --- the "victim": our own toy app ---
victim = random.Random(20261114) # fixed seed for the demo
observed = [victim.getrandbits(32) for _ in range(624)] # public request IDs
secret_next = [f"session={victim.getrandbits(128):032x}" for _ in range(3)]
# --- the observer: rebuild the state from outputs only ---
state = tuple(untemper(v) for v in observed) + (624,)
clone = random.Random()
clone.setstate((3, state, None))
predicted = [f"session={clone.getrandbits(128):032x}" for _ in range(3)]
for real, guess in zip(secret_next, predicted):
print(real, "|", guess, "MATCH" if real == guess else "differ")
session=422ef1299babe8042e3782bd64508529 | session=422ef1299babe8042e3782bd64508529 MATCH
session=abae20e6cce8ed71392090f45c7dec2b | session=abae20e6cce8ed71392090f45c7dec2b MATCH
session=c58430ed86468f7dc780424260531ac3 | session=c58430ed86468f7dc780424260531ac3 MATCH
Rebuilding the state took a median of 1.08 ms (100 rebuilds × 5 runs). The tuple (3, state, None) is the format getstate() returns in CPython 3.9: version 3, 624 words plus a position index, and no cached Gaussian. Pass a tuple of the wrong length and you get ValueError: state vector is the wrong size.
Three details that people get wrong:
- "But I only use
randrange, notgetrandbits." Once the clone has the state, it reproduces every later call,randrange,choiceandshuffleincluded, because they all draw from the same 32-bit stream. Collecting the state is harder when the outputs you see are truncated, but people have published methods for that too. Do not build security on "the attacker sees fewer bits". - "The seed came from
os.urandom." It doesn't matter. The seed only sets the starting state; the outputs still leak the state. - "Nobody will collect 624 values." A busy API hands out 624 IDs in seconds.
Fix: use secrets for anything secret, and keep random for simulations.
import secrets
session_id = secrets.token_urlsafe(32) # 256 bits, cookie-safe
reset_token = secrets.token_urlsafe(32) # put in the URL, store only its hash
api_key = "sk_" + secrets.token_hex(32)
six_digits = f"{secrets.randbelow(10**6):06d}" # OTP: only with attempt limits
Rule: if guessing the value would help an attacker, it comes from the OS CSPRNG, never from a seeded generator.
PHP and other languages
PHP has the same split. mt_rand() and rand() (an alias of mt_rand since PHP 7.1) are Mersenne Twister. random_bytes() and random_int() (PHP 7.0+) read the OS CSPRNG. PHP 8.2 added the Random\Randomizer class, whose default engine is Random\Engine\Secure.
<?php
echo bin2hex(random_bytes(16)), "\n"; // 128-bit hex token
echo rtrim(strtr(base64_encode(random_bytes(16)), '+/', '-_'), '='), "\n"; // URL-safe
echo str_pad((string)random_int(0, 999999), 6, '0', STR_PAD_LEFT), "\n"; // OTP
mt_srand(1114); $a = mt_rand(); mt_srand(1114); $b = mt_rand();
echo "mt_rand same seed: $a $b\n";
$s = new Random\Randomizer();
echo get_class($s->engine), "\n";
echo uniqid(), "\n", uniqid(), "\n";
cd11d73e54d4712cfa62290971d93ec7
a6OmaIUvgPVddjIEO3rPkA
473973
mt_rand same seed: 324694876 324694876
Random\Engine\Secure
6af7e6f032055
6af7e6f032076
Look at the two uniqid() values: they differ only in the last three hex digits, because uniqid() is the current time in microseconds written in hex. It is unique-ish, not secret. Also, PHP 8.5 warns about old habits: MT_RAND_PHP printed Deprecated: Constant MT_RAND_PHP is deprecated since 8.3, as it uses a biased non-standard variant of Mt19937, and lcg_value() printed Deprecated: Function lcg_value() is deprecated since 8.4, use \Random\Randomizer::getFloat() instead.
| Language | Secure source (run here unless noted) | Not for secrets |
|---|---|---|
| Python | secrets.token_bytes/hex/urlsafe, os.urandom |
random.* |
| PHP | random_bytes, random_int, Random\Randomizer() |
mt_rand, rand, uniqid, lcg_value |
| Java 26 | java.security.SecureRandom (printed algorithm NativePRNG) |
java.util.Random, Math.random |
| Dart | Random.secure() |
Random() |
| CLI | openssl rand -hex 32 |
$RANDOM in shells |
| Browser JS | crypto.getRandomValues, crypto.randomUUID (not run here: our jsc shell has no crypto, typeof crypto printed undefined) |
Math.random |
Token length vs guessing time
How long is long enough? Count bits and divide by the guess rate. On average an attacker finds one specific token after trying half the space. The rates below are assumptions, not measurements: 1,000 guesses per second for an online endpoint without rate limiting, and 10⁹ per second for an offline check (for example, if unsalted fast hashes of tokens leaked). The last column assumes a million valid tokens exist at once, so any hit counts.
| Token | Bits | 10³/s online | 10⁹/s offline | 10⁶ live tokens, 10⁹/s |
|---|---|---|---|---|
| 6-digit code | 19.9 | 8.3 min | 0.0005 s | instant |
| 8 hex chars | 32 | 24.9 days | 2.15 s | instant |
| 16 hex chars | 64 | ~10⁸ years | 292 years | 2.56 hours |
| UUIDv4 | 122 | ~10²⁶ years | ~10²⁰ years | ~10¹⁴ years |
token_hex(16) |
128 | ~10²⁸ years | ~10²² years | ~10¹⁶ years |
token_urlsafe(32) |
256 | ~10⁶⁶ years | ~10⁶⁰ years | ~10⁵⁴ years |
Two lessons. First, a 6-digit code is only safe because of what surrounds it: a short expiry and a hard limit on attempts (for example, 5 tries, then the code dies). Second, 64 bits looks huge until many tokens are live at once. Use 128 bits as the floor and 256 as the default; it costs nothing.
UUIDv4 entropy. A version-4 UUID has 128 bits, but 4 are the version nibble and 2 are the variant bits, leaving 122 random bits. CPython's uuid.uuid4() is literally UUID(bytes=os.urandom(16), version=4) (we printed the source with inspect.getsource), so it is CSPRNG-backed in Python. Other libraries and other UUID versions are not: v1 contains a timestamp and a node ID, and v7 starts with a millisecond timestamp. A UUID is an identifier format; reach for secrets when you mean "secret".
Seeding with the clock: measured
The second classic mistake is reseeding per request with the current time, often copied from old C code (srand(time(NULL))).
# timeseed.py - why seeding with the clock is dangerous (local demo)
import random, time
def reset_token(now): # BAD: reseeds per request with the clock
r = random.Random(int(now))
return f"{r.getrandbits(128):032x}"
T = 1794614400 # fixed "server time" (2026-11-14 00:00 UTC)
a = reset_token(T + 0.12) # user A asks for a reset
b = reset_token(T + 0.87) # user B, same second
print("A:", a); print("B:", b); print("same token:", a == b)
victim = reset_token(T + 37.4) # observer knows the time to +-1 minute
cands = [reset_token(s) for s in range(T - 60, T + 61)]
print("candidates:", len(cands), "victim in list:", victim in cands)
A: 7a7c99a76d9b36196739a5195bf659ee
B: 7a7c99a76d9b36196739a5195bf659ee
same token: True
candidates: 121 victim in list: True
Two users in the same second got the same reset token. And whoever knows roughly when a reset was requested (they can trigger it themselves) has only 121 candidates to try. A 128-bit-looking token with about 7 bits of real uncertainty.
How fast are candidates? Generating one token for each of the 86,400 seconds in a day took 1.15 s (median of 5 runs, range 1.146 to 1.158 s), about 75,000 seeds per second on one core. From that measured rate: a whole year of second-resolution seeds is about 7 minutes, and one hour of millisecond seeds (3.6 million) is about 48 seconds. Finer clocks do not save you.
Rule: never seed a generator yourself for security purposes. A CSPRNG needs no seed from you.
Speed: is secure random slow?
The usual excuse is speed. We measured one 128-bit token per call, 200,000 calls × 5 runs, median.
secrets.token_hex(16) cost 2,109 ns against 254 ns for random.getrandbits(128): about 8× slower, and still 2 microseconds. Generating a million tokens takes about 2 seconds. In PHP 8.5 the same comparison gave 310 ns for bin2hex(random_bytes(16)) against 22 ns for mt_rand() (5 × 200,000, median). Neither number is anywhere near the cost of the database write that stores the token.
Honest limits / version notes
- Python 3.9.6 only.
secretswas added in Python 3.6 (PEP 506);random.randbytesin 3.9. We did not test older versions. - The state format
(3, tuple_of_625, None)is a CPython implementation detail; it worked on 3.9.6. - PHP:
random_bytes/random_intsince 7.0,Random\Randomizersince 8.2, deprecation messages shown above are from 8.5.6. - The guessing table uses assumed rates (10³/s and 10⁹/s), not measurements. Real online attacks are bounded by your rate limiting.
- Browser
crypto.getRandomValuesand Node'scrypto.randomByteswere not run here. - Recovering state from truncated outputs (such as
randrange(10**6)) is possible in research literature but we did not demonstrate it.
Exercises
- A token is made of 10 characters picked uniformly from
[a-z0-9]withsecrets.choice. How many bits of entropy is that, and is it enough as a session ID? - Your app stores password reset tokens in the database as plain text. Which other lecture's idea fixes the risk if the table leaks, and what would you store instead?
Answers
- log2(36¹⁰) = 10 × log2(36) ≈ 51.7 bits. Too short for a session ID; at 10⁹ offline guesses per second the expected time is about 21 days for one token and far less with many live tokens. Use
secrets.token_urlsafe(32). - Store a hash of the token (SHA-256 is fine here, because the token itself has 256 random bits, unlike a human password from lecture 2). When the link is used, hash the received token and look it up. Delete it after one use or on expiry.
Next
- Previous: Security Basics 2/6: password hashing with bcrypt and Argon2
- Next: Security Basics 4/6: JWT HS256 signing pitfalls, where a weak secret falls to a 100-word list
- Where tokens travel: cookies and sessions over HTTP and PHP XSS, CSRF and session security
- Comparing a token safely is lecture 6: timing attacks and constant-time compare
FAQ
Is Python's random module safe for session IDs or password reset tokens?
No. random uses the Mersenne Twister (MT19937). In our local demo, 624 consecutive 32-bit outputs were enough to rebuild its full state in about 1 ms, and the clone printed the next three 128-bit session tokens exactly. Use secrets.token_urlsafe(32) or secrets.token_hex(32) instead.
How long should a random token be?
Use at least 128 bits from a CSPRNG; 256 bits (secrets.token_urlsafe(32)) costs nothing extra. Our table shows a 32-bit token falls in about 2 seconds at a billion offline guesses per second, while 128 bits needs around 10^22 years even at that rate.
Is a UUIDv4 good enough as a secret token?
A UUIDv4 from a CSPRNG carries 122 random bits, and Python's uuid.uuid4() reads os.urandom(16), so it is not guessable. It is still a weak habit: other UUID versions (v1, v7) contain timestamps, and the 36-character format is longer than secrets.token_urlsafe(16) for less entropy. Use secrets for secrets.