I learned this lesson the hard way while reviewing an old script of mine that generated “random” API tokens using plain random.randint(). It worked fine functionally, but once I understood how predictable Mersenne Twister output can be if an attacker observes enough of it, I realized every token I’d generated that way was potentially reconstructible. That was a genuinely uncomfortable realization, and it’s why I now treat “random for games” and “random for security” as two completely separate problems requiring two completely different tools. Here’s the full picture.
Why Ordinary random Isn’t Safe for Security
Python’s default random module uses the Mersenne Twister algorithm, a pseudo-random number generator (PRNG) chosen for its speed, extremely long period, and excellent statistical distribution properties — perfect for simulations, games, and general-purpose randomness.
But Mersenne Twister is not cryptographically secure, for a specific, well-documented reason: it has an internal state of 624 32-bit integers (19,937 bits total), and if an attacker observes just 624 consecutive outputs from the generator, they can mathematically reconstruct its entire internal state and predict every future output with certainty. This isn’t a theoretical concern — working implementations of this attack exist and are well documented in the security research community.
import random
# Never do this for anything security-sensitive:
token = "".join(str(random.randint(0, 9)) for _ in range(20))
print(token) # looks random, but is NOT safe for tokens, passwords, or keys
If this token were used for a password reset link, a session ID, or an API key, and an attacker managed to observe enough other outputs from the same generator (even indirectly, through other “random” values your application exposes), they could potentially predict this exact token.
The secrets Module: Python’s Answer for Security
Python 3.6 introduced the secrets module (formalized by PEP 506) specifically to give developers a clear, dedicated tool for cryptographically secure randomness, separate from the general-purpose random module.
import secrets
# Cryptographically secure random integer
print(secrets.randbelow(100)) # random int in range [0, 100)
# Cryptographically secure random bits
print(secrets.randbits(32)) # random 32-bit integer
# Cryptographically secure choice from a sequence
print(secrets.choice(["red", "green", "blue"]))
Under the hood, secrets draws its randomness from os.urandom(), which pulls directly from the operating system’s cryptographically secure random source — on Linux this is typically backed by the kernel’s CSPRNG (drawing from /dev/urandom or the getrandom() syscall), on Windows it uses CryptGenRandom or its modern successor, and on macOS it uses the kernel’s own secure random source. These OS-level generators are specifically designed to resist state-reconstruction attacks, using genuine entropy sources (hardware timing jitter, interrupt timing, and other unpredictable physical phenomena) mixed through cryptographic algorithms.
Generating Secure Tokens for Real Applications
The secrets module includes purpose-built functions for exactly the use cases I run into most — generating tokens for URLs, API keys, and passwords.
import secrets
# URL-safe text token (good for password reset links, session tokens)
token = secrets.token_urlsafe(32)
print(token) # e.g. 'Zx9K3mF...' — safe to embed directly in a URL
# Hex-encoded token
hex_token = secrets.token_hex(16)
print(hex_token) # 32 hex characters, representing 16 random bytes
# Raw bytes, if you need to encode them yourself
raw_bytes = secrets.token_bytes(16)
print(raw_bytes)
The number passed to these functions specifies the number of random bytes generated, not the length of the final string — token_hex(16) produces 16 random bytes, which become 32 hex characters (since each byte is represented by 2 hex digits), and token_urlsafe(32) produces 32 random bytes, base64url-encoded into a somewhat longer string.
Choosing an Appropriate Token Length
The right number of bytes depends on what you’re protecting against. A common guideline I follow:
import secrets
# Session tokens / password reset links: at least 32 bytes (256 bits) recommended
session_token = secrets.token_urlsafe(32)
# API keys: similarly substantial, often 32 bytes or more
api_key = secrets.token_hex(32)
More bytes means more possible values, which means a brute-force guessing attack becomes astronomically less feasible. The secrets module documentation itself recommends at least 32 bytes (256 bits) for security-sensitive tokens as a solid general-purpose default, as of recent guidance in the official docs — always double check the specific number against current recommendations if you’re working on something with unusually high security requirements.
Generating Secure Random Passwords
import secrets
import string
def generate_password(length=16):
alphabet = string.ascii_letters + string.digits + string.punctuation
return "".join(secrets.choice(alphabet) for _ in range(length))
print(generate_password())
I use secrets.choice() here rather than random.choice() specifically because password generation is a textbook security-sensitive use case — the whole point of a password is that it should be unpredictable to anyone but its owner.
Comparing Values Securely: secrets.compare_digest()
A subtler security issue arises when comparing secret values, like checking whether a submitted token matches a stored one. A naive == comparison in Python short-circuits as soon as it finds a mismatched character, which means the time taken to return False can vary depending on how many leading characters matched — this is called a timing attack, and it can, in principle, let an attacker guess a secret one character at a time by measuring tiny differences in response time.
import secrets
stored_token = "a1b2c3d4e5f6"
submitted_token = "a1b2c3d4e5f6"
# Vulnerable to timing attacks:
# if stored_token == submitted_token: ...
# Safe, constant-time comparison:
if secrets.compare_digest(stored_token, submitted_token):
print("Tokens match")
else:
print("Tokens do not match")
secrets.compare_digest() is specifically implemented to take the same amount of time regardless of where (or whether) a mismatch occurs, closing off this entire class of attack.
Random Integers Within a Range
import secrets
# Secure random integer in [0, n)
n = 100
value = secrets.randbelow(n)
print(value)
# Secure random integer in an arbitrary inclusive range [a, b]
def secure_randint(a, b):
return a + secrets.randbelow(b - a + 1)
print(secure_randint(10, 20))
secrets.randbelow() is the building block for range-limited secure integers, similar in spirit to random.randrange() but backed by the secure generator instead of Mersenne Twister.
When random Is Still the Right Choice
I want to be clear that secrets isn’t simply “better random” in every sense — it’s slower, because reading from the OS’s entropy-backed CSPRNG has real overhead compared to the pure userspace Mersenne Twister algorithm. For anything that isn’t security-sensitive — game mechanics, shuffling a deck of cards for a casual game, simulations, statistical sampling — random (or numpy.random for large-scale vectorized work) remains the right, faster tool.
import random
import secrets
import timeit
print("random.randint: ", timeit.timeit(lambda: random.randint(0, 100), number=100_000))
print("secrets.randbelow:", timeit.timeit(lambda: secrets.randbelow(100), number=100_000))
In my own testing, secrets functions are consistently, sometimes substantially, slower than their random counterparts — which is expected, and an acceptable trade-off precisely because it’s only used where security actually matters.
The os.urandom() Foundation
For completeness, it’s worth understanding that secrets (and the security-oriented parts of the random module, like random.SystemRandom) are built on top of os.urandom(n), which returns n random bytes directly from the OS’s secure source.
import os
random_bytes = os.urandom(16)
print(random_bytes)
random.SystemRandom is actually an older, pre-secrets way to access this same OS-backed randomness, still present in the random module for backward compatibility:
import random
secure_random = random.SystemRandom()
print(secure_random.randint(1, 100))
print(secure_random.choice(["a", "b", "c"]))
Since Python 3.6, secrets is the clearer, more explicitly named, and generally recommended interface for this purpose, but SystemRandom is functionally equivalent under the hood if you encounter it in older codebases.
Real-World Applications
- Session tokens and authentication cookies, where predictability would let attackers hijack sessions.
- Password reset links, where a guessable token would let an attacker take over any account.
- API keys and secrets for service-to-service authentication.
- CSRF tokens, protecting web forms from cross-site request forgery.
- One-time codes for two-factor authentication flows, where the code must be genuinely unpredictable within its short validity window.
- Cryptographic key generation (though for actual cryptographic keys used with specific algorithms, purpose-built libraries like
cryptographyare typically more appropriate than rolling your own key material with rawsecretscalls).
Common Mistakes
Using random anywhere in an authentication or authorization flow. This is the single most important mistake this entire guide exists to prevent — always use secrets for tokens, passwords, and any security-sensitive random value.
Comparing secret values with == instead of secrets.compare_digest(), leaving an application vulnerable to timing attacks, however subtle.
Generating tokens that are too short. A 4-byte token might feel “random enough” casually, but it only has 2^32 possible values, which is brute-forceable given enough time and requests — stick to well-established length recommendations like 32 bytes for anything genuinely sensitive.
Reusing secrets for high-frequency, non-sensitive randomness, incurring unnecessary performance overhead where plain random would have been entirely appropriate.
Assuming HTTPS alone protects against timing attacks on comparisons. Transport encryption protects data in transit, but it doesn’t protect against an attacker measuring response timing differences at the server, which is exactly what compare_digest() guards against.
Debugging Tips
- If you’re auditing an existing codebase for security issues, search specifically for
random.calls near words liketoken,password,secret,key, orsession— these are red flags worth investigating. - Verify token uniqueness assumptions statistically if it matters for your application (e.g., checking collision rates over a very large number of generated tokens), though with sufficiently long tokens (32+ bytes) collisions are astronomically unlikely.
- When reviewing comparison logic for anything secret, check specifically whether
==orsecrets.compare_digest()is being used.
Performance Considerations
secretsfunctions are slower thanrandomequivalents because they read from the OS’s entropy-backed secure source rather than a fast in-process PRNG — this overhead is the necessary cost of the security guarantee.- For bulk, non-sensitive random generation (like generating thousands of test data rows),
randomornumpy.randomremain the appropriate, faster choice. - Token generation itself (even with
secrets) is fast enough in absolute terms that it’s essentially never a bottleneck for typical web application authentication flows — the overhead compared torandomis real but rarely operationally significant at normal request volumes.
FAQs
Is secrets overkill for a simple internal script? If nothing about the random value is security-sensitive (no tokens, passwords, or secret comparisons), random remains perfectly appropriate and faster.
Can I seed secrets for reproducible testing like I can with random.seed()? No — secrets intentionally provides no seeding mechanism, since reproducibility would directly undermine the unpredictability guarantee that’s the entire point of the module.
Is secrets.token_hex() or secrets.token_urlsafe() better for web tokens? token_urlsafe() is generally preferred for anything embedded directly in a URL, since it avoids characters that need escaping; token_hex() is fine for tokens stored server-side or transmitted in headers rather than URLs.
Does using secrets guarantee my application is fully secure? No — it solves the specific problem of generating unpredictable random values and comparing secrets safely. Overall application security depends on many other factors (proper HTTPS use, secure storage, correct session handling, and so on) that secrets alone doesn’t address.
Summary
Cryptographically secure randomness in Python means deliberately stepping away from the default random module’s Mersenne Twister generator — fast and statistically excellent, but predictable given enough observed output — and reaching for the secrets module instead, which draws from the operating system’s genuinely unpredictable entropy sources. Whether generating session tokens, password reset links, or API keys, secrets.token_urlsafe(), secrets.token_hex(), and secrets.compare_digest() give you the right tools purpose-built for security contexts, while random remains the correct, faster choice for everything else. Knowing precisely when to use which is one of the most practically important distinctions in writing secure Python code.
References
- Python official documentation:
secretsmodule - Python official documentation:
os.urandom - Python official documentation:
random.SystemRandom - PEP 506, “Adding A Secrets Module To The Standard Library,” on peps.python.org