CWE-331: Insufficient Entropy - Python
Overview
Insufficient entropy is a question about the number of unpredictable bits in a value, not about which module produced it. In Python it shows up in two shapes. The first is a seeded generator: random.seed(int(time.time())) caps everything MT19937 goes on to produce at the range of the seed - a few tens of thousands of candidates for a known day, whatever the generator's 2^19937 period says. The second is an output that is simply too small: secrets.token_hex(4) is 32 bits, a six-digit code is under 20, and both fall to a brute-force even though the source is impeccable.
Primary Defence: Size the value against what it protects - at least 16 bytes for a token and 32 for key material - then draw it with the secrets module, which reads from the OS and, deliberately, cannot be seeded.
The related finding that the generator is not cryptographic - random, uuid.uuid1(), random.shuffle() - is CWE-338. The two are usually reported on the same line and both are covered below.
Common Vulnerable Patterns
Using random module for token generation
import random
# VULNERABLE - Predictable token generation
def generate_token():
token = ''.join(random.choice('0123456789abcdef') for _ in range(32))
return token
Why this is vulnerable:
randomuses MT19937, which is fast but not cryptographically secure.- The internal state can be recovered from enough outputs, enabling prediction.
- Deterministic output makes tokens guessable.
Time-based random seeding
import random
import time
# VULNERABLE - Time-based seed
random.seed(int(time.time()))
session_id = random.randint(0, 1000000)
Why this is vulnerable: The output space is the seed space. MT19937 has
a period of 2^19937 and that number is irrelevant here: once random.seed()
fixes the state, every value the module produces afterwards is a function of
the seed alone. int(time.time()) gives one second of granularity, so an
attacker who knows the day has about 86,400 candidates and one who knows the
month has 2.6 million. Replaying them offline means calling
random.seed(t); random.randint(0, 1000000) for each t and comparing against
an observed session ID - measured on CPython 3.13, 0.7 seconds for the day and
22 seconds for the month, in plain Python with no optimisation at all.
Widening randint(0, 1000000) does not help, because
the range was never the constraint. os.getpid(), an auto-increment user ID
and a request counter all fail the same way and are smaller still.
Using random for encryption keys
import random
# VULNERABLE - Using random for encryption key
encryption_key = bytes([random.randint(0, 255) for _ in range(32)])
Why this is vulnerable:
- Encryption keys must be unpredictable;
randomis deterministic. - If the PRNG state is recovered, keys can be reproduced.
- A key an attacker can reproduce protects nothing encrypted under it.
Using random for IVs and nonces
import random
# VULNERABLE - Random IV generation
iv = bytes([random.getrandbits(8) for _ in range(16)])
Why this is vulnerable:
- CBC IVs must be unpredictable; AEAD nonces such as AES-GCM nonces must be unique and never repeat under the same key.
randomoutput is predictable and can repeat across runs.- Nonce reuse under the same key breaks AES-GCM.
Using random for password reset tokens
import random
# VULNERABLE - Password reset token
reset_token = ''.join(random.choices('0123456789', k=6))
Why this is vulnerable:
- Reset tokens grant account access, so they must be unguessable.
- Six digits provides ~20 bits of entropy, which is brute-forceable.
- On top of that,
randomoutput follows from an MT19937 state an attacker can recover.
Using random for API keys
import random
# VULNERABLE - API key generation
api_key = ''.join(random.sample('ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789', 20))
Why this is vulnerable:
- API keys are long-lived credentials and must be unguessable.
random.sample()is predictable if the PRNG state is known.- Sampling without replacement reduces the keyspace.
Weak sources that do not look like random number generators
The calls above announce themselves. These do not, which is why they survive review:
# VULNERABLE - all of these draw on the same Mersenne Twister state
password = ''.join(random.sample(string.ascii_letters, 12))
random.shuffle(prize_draw_entries)
winner = random.choice(eligible_users)
# VULNERABLE - uuid1 is not random; it encodes the host MAC address and time
import uuid
reset_token = str(uuid.uuid1())
Why this is vulnerable: random.sample(), random.shuffle(), and
random.choice() all draw from the same generator as random.random(), so an
attacker who recovers its state reproduces the result regardless of which
function produced it. uuid.uuid1() is worse than weak randomness: it is
largely not random, encoding the machine's MAC address and a timestamp, so
two tokens issued close together differ in only a few characters. uuid.uuid4()
is CSPRNG-backed and acceptable for identifiers, though its 122 bits are short
of what you want for key material.
Secure Patterns
Using secrets module (Python 3.6+)
import secrets
# SECURE - Session token generation (128+ bits)
def generate_session_token():
"""Generate cryptographically secure session token (32 hex chars = 128 bits)"""
return secrets.token_hex(16) # 16 bytes = 128 bits
# SECURE - URL-safe token (base64-encoded)
def generate_url_safe_token():
"""Generate URL-safe token for password resets, CSRF, etc.
token_urlsafe() takes a count of BYTES, not characters: 32 bytes of
entropy come back as a 43-character string, each character carrying
6 bits. Sizing a database column by that 32 truncates the stored
token to 32 * 6 = 192 bits on write.
"""
return secrets.token_urlsafe(32) # 32 bytes = 256 bits -> 43 chars
# SECURE - Cryptographic key generation
def generate_encryption_key(key_size=32):
"""Generate AES-256 key (256 bits = 32 bytes)"""
return secrets.token_bytes(key_size)
# SECURE - IV/nonce generation
def generate_iv(size=16):
"""Generate secure IV for AES (128 bits = 16 bytes)"""
return secrets.token_bytes(size)
# SECURE - CSRF token
def generate_csrf_token():
"""Generate CSRF protection token"""
return secrets.token_urlsafe(32)
# SECURE - API key generation
def generate_api_key():
"""Generate API key with 256 bits of entropy"""
return secrets.token_urlsafe(32) # 32 bytes = 256 bits
def generate_otp(length=6):
"""One-time code delivered out of band. NOT a token.
Six digits is 6 * log2(10) = 19.9 bits - the same ~20 bits the reset
token above is marked VULNERABLE for. Moving to secrets fixes the
generator and changes the entropy not at all. What makes this usable
is the attempt limit, the short expiry and single-use enforcement at
the call site. Without those three, lengthen the code instead.
"""
return ''.join(secrets.choice('0123456789') for _ in range(length))
Why this works:
- The byte counts are chosen against what each value protects rather than against how the string looks: 16 bytes is the 128-bit floor for a session token, 32 bytes is 256 bits for anything long-lived.
token_hex(n)andtoken_urlsafe(n)both take bytes, so those render as 32 and 43 characters - measuring the string is how 16 bytes gets mistaken for 32. secretsreads fromos.urandom()and has no seed a caller can narrow.random.seed()has no counterpart here, which is the point rather than an omission.secrets.choice()draws with rejection sampling, so a small alphabet does not reintroduce bias. Reducing a random byte with% 10would: 256 is not a multiple of 10, so digits 0-5 appear 26 times per 256 bytes against 25 for 6-9.
Using os.urandom() (all Python versions)
import os
import base64
import binascii
# SECURE - Random bytes from OS
def generate_random_bytes(size=32):
"""Generate cryptographically secure random bytes"""
return os.urandom(size)
# SECURE - Hex-encoded token
def generate_hex_token(size=32):
"""Generate hex token (64 hex chars = 256 bits)"""
return binascii.hexlify(os.urandom(size)).decode('utf-8')
# SECURE - Base64-encoded token
def generate_base64_token(size=32):
"""Generate base64-encoded token"""
return base64.urlsafe_b64encode(os.urandom(size)).decode('utf-8').rstrip('=')
Why this works:
os.urandom()reads from OS CSPRNG sources directly.- 32 bytes (256 bits) of entropy is sufficient for tokens and API keys.
- Hex/base64 encodings preserve the underlying entropy.
Complete encryption example with secure randomness
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC
import secrets
class SecureEncryption:
"""Example of secure encryption with proper randomness"""
@staticmethod
def generate_salt(size=16):
"""Generate salt for key derivation"""
return secrets.token_bytes(size)
@staticmethod
def derive_key(password: str, salt: bytes) -> bytes:
"""Derive encryption key from password using PBKDF2"""
kdf = PBKDF2HMAC(
algorithm=hashes.SHA256(),
length=32, # 256 bits
salt=salt,
iterations=600000,
)
return kdf.derive(password.encode())
@staticmethod
def encrypt(plaintext: bytes, key: bytes) -> tuple:
"""Encrypt with AES-GCM (authenticated encryption)"""
# Generate secure nonce (96 bits for GCM)
nonce = secrets.token_bytes(12)
aesgcm = AESGCM(key)
ciphertext = aesgcm.encrypt(nonce, plaintext, None)
return nonce, ciphertext
@staticmethod
def decrypt(nonce: bytes, ciphertext: bytes, key: bytes) -> bytes:
"""Decrypt AES-GCM ciphertext"""
aesgcm = AESGCM(key)
return aesgcm.decrypt(nonce, ciphertext, None)
# Usage example
password = "user_password"
salt = SecureEncryption.generate_salt()
key = SecureEncryption.derive_key(password, salt)
plaintext = b"sensitive data"
nonce, ciphertext = SecureEncryption.encrypt(plaintext, key)
decrypted = SecureEncryption.decrypt(nonce, ciphertext, key)
assert plaintext == decrypted
Why this works:
- A CSPRNG salt plus PBKDF2 slows brute-force attacks and avoids rainbow tables.
- The 96-bit nonce is generated securely to prevent reuse.
- AES-GCM provides authenticated encryption with integrity protection.
Framework-Specific Guidance
Django - Session and CSRF Tokens
# Django automatically uses secure randomness for sessions and CSRF
# settings.py
SESSION_ENGINE = 'django.contrib.sessions.backends.db' # Uses secrets internally
CSRF_USE_SESSIONS = True
# Generate custom secure tokens in Django
from django.utils.crypto import get_random_string
# SECURE - Django's secure token generator
def generate_verification_token():
"""Django helper uses secrets module internally"""
return get_random_string(length=32)
# SECURE - Custom token with allowed characters
def generate_alphanumeric_token():
return get_random_string(
length=40,
allowed_chars='abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'
)
Why this works:
- Django uses secure randomness for session and CSRF tokens by default.
get_random_string()draws from thesecretsmodule.
Flask - Session Management
import os
import secrets
from flask import Flask, session
app = Flask(__name__)
# SECURE - 32 bytes = 256 bits. Mint it once with secrets.token_hex(32) and
# load it from the environment; generating it here gives every worker process
# and every restart a different key, so signed sessions silently stop
# validating and users are logged out at random.
app.config['SECRET_KEY'] = os.environ['FLASK_SECRET_KEY']
if len(bytes.fromhex(app.config['SECRET_KEY'])) < 32:
raise RuntimeError('FLASK_SECRET_KEY must be at least 32 bytes (64 hex characters)')
# SECURE - Generate secure session tokens
def generate_session_id():
return secrets.token_urlsafe(32)
@app.route('/login', methods=['POST'])
def login():
# Flask sessions use SECRET_KEY for signing (already secure)
session['user_id'] = user.id
session['csrf_token'] = secrets.token_hex(16)
return redirect('/dashboard')
Why this works:
SECRET_KEYand CSRF tokens come from CSPRNG output.- Flask sessions are signed with the secure key.
FastAPI - API Key Generation
from fastapi import FastAPI, HTTPException, Depends, Header
from typing import Optional
import secrets
app = FastAPI()
# Store API keys securely (use database in production)
valid_api_keys = set()
def generate_api_key() -> str:
"""Generate secure API key"""
return secrets.token_urlsafe(48)
def verify_api_key(x_api_key: Optional[str] = Header(None)):
"""Verify API key"""
if x_api_key not in valid_api_keys:
raise HTTPException(status_code=401, detail="Invalid API key")
return x_api_key
@app.post("/api/keys")
def create_api_key():
"""Create new API key"""
new_key = generate_api_key()
valid_api_keys.add(new_key)
return {"api_key": new_key}
@app.get("/protected", dependencies=[Depends(verify_api_key)])
def protected_endpoint():
return {"message": "Access granted"}
Why this works:
- API keys are generated with
secrets, notrandom. - Server-side verification ensures only issued keys are accepted.
Considerations
Not every random value is a secret. The filename of a cached thumbnail, the
order of quiz questions, a sample drawn for a report, jitter on a retry - none
of these gain an attacker anything if guessed, and random is the right module
for them. The question is not "is this random" but "does guessing it get
someone something". Session IDs, reset and verification tokens, API keys, CSRF
tokens, OTPs, salts, IVs and key material all fail that test. If the value is
not one of those, record the finding as a false positive with the reason rather
than switching to secrets reflexively.
Length is a separate decision from algorithm. secrets.token_hex(4) comes
from a CSPRNG and is still only 32 bits, which is brute-forceable. Use at least
16 bytes for tokens and 32 for key material. Both token_hex(n) and
token_urlsafe(n) take a count of bytes, so the string you see is longer
than n - which is where 8 bytes gets mistaken for 16.
A short lifetime buys less than it appears to. It is tempting to argue that a 15-minute single-use token needs less entropy. Rate limiting and expiry do raise the cost of an online attack, but they do nothing about an attacker who can request many tokens and study them, and the saving is worth a few bytes at most. Use the standard sizes; spend the effort on expiry and single-use enforcement instead, which are worth having regardless.
secrets deliberately cannot be seeded. If code needs reproducible values
for a test fixture, that is a sign the generator belongs behind an interface
the test can substitute, not a reason to keep random on the production path.
Reproducibility and unpredictability are the same property viewed from
opposite sides.
Do not try to strengthen a weak value. Hashing it, encoding it, or concatenating a timestamp changes its appearance without adding entropy - the result is still determined by the weak input. Replace the source.
Common Pitfalls
- Deriving multiple secrets from one CSPRNG call: Generating a
secrets.token_bytes(32)value correctly, then slicing or concatenating it to produce a session token, CSRF token, and API key instead of callingsecrets.token_*()independently for each - correlated derivation can leak relationships between the values even though the original source was strong. - Caching a nonce at import time: Generating a nonce with
os.urandom(12)correctly, but storing it as a module-level constant (NONCE = os.urandom(12)) computed once and reused across everyAESGCM.encrypt()call - a single correctly-generated nonce still breaks AES-GCM if it's reused across encryptions under the same key. Generate a fresh nonce per call.
Additional Resources
- Cryptography Library Documentation
- CWE-331: Insufficient Entropy
- OWASP Cryptographic Storage Cheat Sheet
- OWASP Password Storage Cheat Sheet - the source for the PBKDF2 iteration count, bcrypt work factor, and Argon2 parameters used here
- Python os.urandom() Documentation
- Python secrets Module Documentation