Home Tutorials Standard Library

Standard Library

Python's secrets Module: Cryptographically Secure Tokens and Randomness

Pyford Notes July 6, 2026 7 min read
Key points
  • random uses a Mersenne Twister generator that is fully predictable once enough of its output is observed -- never use it for anything security-sensitive.
  • secrets draws from the operating system's cryptographic random source (os.urandom), which is designed to be unpredictable even to an attacker watching output.
  • secrets.token_urlsafe() and token_hex() are the standard way to generate session tokens, password reset tokens, and API keys.
  • Comparing secret values (like a token from a request against a stored one) should use secrets.compare_digest(), not ==, to avoid timing attacks.

Why the random module is unsafe for security

Python's random module is built on a Mersenne Twister pseudo-random number generator, chosen for statistical quality and speed, not unpredictability against an adversary. Its output is fully deterministic given its internal state, and that state can, in principle, be reconstructed from a large enough sample of its output — a well-known property of Mersenne Twister generators generally. That's a non-issue for simulations, games, and sampling, but disqualifies random entirely for anything where guessing the next value has real consequences: session tokens, password reset links, API keys, or CSRF tokens. secrets, added in Python 3.6 specifically to close this gap, draws from the operating system's cryptographically secure random source instead.

Generating tokens for sessions and API keys

secrets.token_hex() and secrets.token_urlsafe() are the two functions you'll reach for most often, differing only in output format:

import secrets

session_token = secrets.token_hex(32)      # 64 hex characters = 32 bytes of randomness
api_key = secrets.token_urlsafe(32)          # URL-safe base64, roughly 43 characters

print(session_token)   # e.g. "a3f1c9e8b2d4..." -- safe to store, log, or send as a header
print(api_key)          # e.g. "kR9x2mQvL7z..." -- safe to embed directly in a URL

The integer argument is the number of random bytes generated, not the length of the resulting string — token_hex(32) produces 32 bytes of entropy encoded as 64 hex characters, and token_urlsafe(32) encodes the same 32 bytes as base64, which is shorter per byte than hex but still URL-safe. 32 bytes (256 bits) is comfortably more than enough entropy for a session or API token; going much higher adds length without meaningfully improving security.

secrets.choice() and randbelow()

For picking a securely random element from a sequence, or a random integer below a bound, secrets provides direct equivalents to the familiar random functions, but backed by the secure source:

import secrets, string

alphabet = string.ascii_letters + string.digits
password = "".join(secrets.choice(alphabet) for _ in range(16))
print(password)   # a securely random 16-character password

verification_code = secrets.randbelow(1_000_000)   # a secure random int in [0, 1_000_000)
print(f"{verification_code:06d}")   # e.g. "042817"

secrets deliberately does not include a full port of every random function — there's no secrets.gauss() or secrets.shuffle(), for instance — because the module's scope is intentionally narrow: security-sensitive random values, not general-purpose statistical sampling. For anything needing a distribution shape or reproducible sequences, random remains the correct tool; the two modules serve different purposes and shouldn't be mixed up.

Comparing secret values safely

Once you have a secret token, comparing an incoming value against a stored one needs the same timing-attack protection discussed for HMAC signatures: a plain == comparison on strings returns as soon as it hits the first mismatched character, and the resulting timing difference can, over enough attempts, leak information about how many leading characters were correct:

import secrets

def verify_token(submitted, expected):
    return secrets.compare_digest(submitted, expected)   # constant-time comparison

stored_token = "a3f1c9e8b2d4..."
user_submitted = get_token_from_request()
if verify_token(user_submitted, stored_token):
    grant_access()

secrets.compare_digest() and hmac.compare_digest() are the same underlying function exposed from two modules for convenience — either import works, and both take the same constant time regardless of where, or whether, the two values differ.

How much randomness is actually enough

128 bits of entropy (16 random bytes) is already far beyond what's brute-forceable with any foreseeable computing power, and 256 bits (32 bytes) is the conventional choice when there's no strong reason to economise on token length. Numeric codes sent over SMS or email for two-factor verification are a deliberate exception: those are typically 6 digits (about 20 bits), which is fine specifically because they're rate-limited and expire quickly, trading raw entropy for something short enough for a human to type. The module's full API, including token_bytes() for raw bytes output, is documented at the secrets module reference, alongside explicit recommendations on token lengths for common use cases.