The secrets module, added in Python 3.6 (PEP 506), generates cryptographically secure random numbers suitable for managing secrets such as account authentication, tokens, and passwords. It draws from the operating system’s most secure source of randomness, unlike the random module.
Cryptographically secure randomness
What you will learn: why secrets exists, how it differs from random, and how to generate secure tokens for passwords, API keys, and session IDs.
How to read this tab: Remember the absolute rule: anything an attacker must not guess uses secrets, never random.
When you need randomness that an attacker must never be able to guess — a password reset token, an API key, a session ID — you use secrets, not random. The random module is predictable; secrets is not.
secrets has no seed() — and that is the point. random can be seeded to reproduce a sequence, which is exactly what makes it unsafe for security. secrets draws from the OS entropy source and cannot be made reproducible. Reproducible means predictable, and predictable secrets are not secret.
Why secrets exists
The random module is fast and reproducible, which is perfect for simulations and games — and a disaster for security, because its output can be predicted. secrets draws from the operating system's cryptographically secure randomness source, making its output genuinely unpredictable.
import secrets
# Secure random integer in [0, n)
print(secrets.randbelow(100)) # e.g. 73 — unpredictable
# Secure random selection from a sequence
options = ["red", "green", "blue"]
print(secrets.choice(options)) # cryptographically secure choice
# Secure random bits
print(secrets.randbits(16)) # a secure 16-bit integer
# Compare with random — NEVER use this for security:
import random
random.seed(42)
print(random.randbelow if False else "") # random has no randbelow;
# random.randint(0, 99) is predictable once the seed/state is known.
# secrets has NO seed() — you cannot make it reproducible, by design.You cannot seed secrets — there is deliberately no way to make its output reproducible. Reproducibility is exactly what you do not want for a security token. If you need reproducible randomness for testing, that code is not security-sensitive and should use random instead.
token_urlsafe for URLs, token_hex for keys. token_urlsafe(32) gives a ~43-char URL-safe string ideal for password-reset links. token_hex(16) gives 32 hex characters for API keys and session IDs. 32 bytes (256 bits) is ample for secrets that must resist brute force indefinitely.
Generating tokens
import secrets
# URL-safe token — for password resets, email confirmations, etc.
print(secrets.token_urlsafe(32)) # e.g. 'Drmhze6EPcv0fN_81Bj-nA...'
# 32 bytes of randomness, encoded URL-safe (~43 characters)
# Hex token — for API keys, session IDs
print(secrets.token_hex(16)) # e.g. 'f9bf78b9a18ce6d46a0cd2b0...'
# 16 bytes -> 32 hex characters
# Raw bytes — when you need binary
print(secrets.token_bytes(16)) # e.g. b'\xf9\xbf...' (16 secure bytes)
# How many bytes? A common guideline: 32 bytes (256 bits) is ample
# for tokens that must resist brute force indefinitely.
api_key = secrets.token_urlsafe(32)
reset_token = secrets.token_urlsafe(32)✅ Beginner tab complete
- I know secrets is for security; random is for simulations/games
- I can generate a token with token_urlsafe and token_hex
- I know secrets has no seed() — it cannot be made reproducible
- I know to use secrets for passwords, tokens, keys, session IDs
Passwords, PINs, and constant-time compare
What you will learn: composing secrets primitives into secure passwords and OTPs, and using compare_digest to defeat timing attacks.
How to read this tab: Always use secrets.choice (not random.choice) when building passwords, and compare_digest when checking tokens.
Always secrets.choice, never random.choice, for passwords. Build a password by drawing each character with secrets.choice over an alphabet. For a numeric OTP, join secrets.choice(string.digits) and keep it as a string so leading zeros survive.
Generating passwords and PINs
You can compose secrets primitives to build secure passwords, passphrases, and one-time codes. The key is to always use secrets.choice, never random.choice.
import secrets
import string
# A secure random password from letters, digits, and symbols
alphabet = string.ascii_letters + string.digits + string.punctuation
password = "".join(secrets.choice(alphabet) for _ in range(16))
print(password) # e.g. 'k$2Lm9#pQr!7Zx@N'
# A password meeting complexity rules — regenerate until satisfied
def strong_password(length=12):
alphabet = string.ascii_letters + string.digits + string.punctuation
while True:
pw = "".join(secrets.choice(alphabet) for _ in range(length))
if (any(c.islower() for c in pw) and any(c.isupper() for c in pw)
and any(c.isdigit() for c in pw)
and any(c in string.punctuation for c in pw)):
return pw
print(strong_password())
# A memorable passphrase from a word list (XKCD-style)
words = ["correct", "horse", "battery", "staple", "mountain", "river"]
passphrase = "-".join(secrets.choice(words) for _ in range(4))
print(passphrase) # e.g. 'river-staple-horse-mountain'
# A numeric one-time code (OTP)
otp = "".join(secrets.choice(string.digits) for _ in range(6))
print(otp) # e.g. '042917' (leading zeros preserved — it's a string)Use compare_digest to check secrets, not ==. A plain == returns as soon as it finds a mismatch, so the time taken leaks how many leading characters were correct — letting an attacker guess a token one character at a time. compare_digest always takes the same time.
Constant-time comparison
When comparing secrets (like checking a submitted token against the stored one), a normal == comparison can leak information through timing. secrets.compare_digest compares in constant time, defeating timing attacks.
import secrets
stored_token = "a1b2c3d4e5f6"
def check_token(submitted):
# WRONG: submitted == stored_token
# == returns as soon as it finds a mismatched character, so the
# time taken leaks how many leading characters were correct.
# RIGHT: constant-time comparison
return secrets.compare_digest(submitted, stored_token)
print(check_token("a1b2c3d4e5f6")) # True
print(check_token("wrong")) # False
# compare_digest always takes the same time regardless of where
# (or whether) the strings differ — no timing information leaks.✅ Intermediate tab complete
- I can build a secure password with secrets.choice over an alphabet
- I can generate a numeric OTP as a string (preserving leading zeros)
- I use secrets.compare_digest to compare tokens in constant time
- I know == leaks timing information when comparing secrets
os.urandom, entropy, and token sizing
What you will learn: how secrets uses os.urandom and the OS CSPRNG, recommended token sizes, and the limits of compare_digest.
How to read this tab: Read to understand exactly why the OS entropy source is unpredictable where the Mersenne Twister is not.
os.urandom, entropy sources, and token sizing
Under the hood, secrets uses random.SystemRandom, which draws from os.urandom() — the operating system's cryptographically secure pseudo-random number generator (CSPRNG). On Linux this is the getrandom() syscall backed by the kernel entropy pool; on Windows it is BCryptGenRandom. These sources are designed so that observing past output gives no usable information about future output, which is precisely the property the Mersenne Twister in random lacks.
import secrets, os
# secrets is a thin, safe layer over os.urandom
raw = os.urandom(16) # 16 cryptographically secure bytes
print(secrets.token_hex(16) == secrets.token_hex(16)) # False (always)
# Token sizing — the default nbytes
# secrets.token_bytes() with no argument uses a reasonable default (32)
print(len(secrets.token_bytes())) # 32 by default
# Sizing guidance from PEP 506 / docs:
# - 16 bytes (128 bits): minimum for most purposes
# - 32 bytes (256 bits): recommended for long-lived secrets
# token_urlsafe(n) produces ceil(n * 4 / 3) characters (base64url)
print(len(secrets.token_urlsafe(32))) # 43 characters
# secrets.choice uses SystemRandom under the hood
sr = secrets.SystemRandom() # same generator secrets uses
print(sr.randint(1, 100)) # secure, not seedable
# Why constant-time matters — compare_digest works on str (ASCII) or bytes
import hmac
# secrets.compare_digest is the same primitive as hmac.compare_digest
print(secrets.compare_digest(b"abc", b"abc")) # True
# Both compare every byte regardless of early mismatches.A subtle but important detail: secrets.compare_digest only guarantees constant time with respect to matching content — it still reveals the length of the strings through timing, so it should compare values of fixed or already-known length (such as fixed-size tokens or hash digests). For password verification specifically, you should not store or compare raw passwords at all — you store a salted hash from a slow password-hashing function (covered in the hashlib page) and compare digests. The secrets module handles secure generation; password storage is a separate concern requiring deliberately slow hashing.
✅ Expert tab complete
- I know secrets draws from os.urandom (the OS CSPRNG)
- I know 32 bytes/256 bits is recommended for long-lived secrets
- I know compare_digest still reveals length, so compare fixed-size values
- I know password storage needs slow hashing, not just secure generation