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, not getrandbits." Once the clone has the state, it reproduces every later call, randrange, choice and shuffle included, 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.
Flow diagram. Top row: an app uses random.Random for both public 32-bit IDs and secret tokens. An observer collects 624 consecutive public outputs, untempers each one back into a state word, loads the 624 words into a clone with setstate, and the clone prints the next secret tokens exactly. Bottom row, the fix: the app uses secrets or os.urandom, which read the operating system's random source; getstate raises NotImplementedError, so there is nothing to clone.
Why random must not mint tokens: MT19937 keeps 624 words of state, every 32-bit output is a reversible scramble of one word, so 624 observed outputs rebuild the whole generator. secrets reads the OS source and has no state to copy.

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.

Bar chart of nanoseconds per token. random.getrandbits(128) formatted as hex: 254. os.urandom(16): 1882. secrets.token_hex(16): 2109. secrets.token_urlsafe(16): 2362. str(uuid.uuid4()): 3506.
Cost of one 128-bit token in Python 3.9 on an Apple Silicon Mac (M1 Pro), median of 5 runs × 200,000 tokens. The secure options are about 8× slower than random and still cost only about 2 µs.

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. secrets was added in Python 3.6 (PEP 506); random.randbytes in 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_int since 7.0, Random\Randomizer since 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.getRandomValues and Node's crypto.randomBytes were 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

  1. A token is made of 10 characters picked uniformly from [a-z0-9] with secrets.choice. How many bits of entropy is that, and is it enough as a session ID?
  2. 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

  1. 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).
  2. 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

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.

  • #security
  • #intermediate
  • #python
  • #php
  • #random
  • #tokens
  • #OWASP

t.laboratory

A technical blog that records real problems from the apps, servers and services we build and run — and the code that fixed them.

About · Apps

↑