Skip to content

CWE-338: Use of Cryptographically Weak Pseudo-Random Number Generator (PRNG) - Python

Overview

Python applications hit this weakness when a security-sensitive value comes from the random module instead of a cryptographic generator. random uses the Mersenne Twister algorithm, whose internal state can be reconstructed from its own output, so an attacker who collects enough values can predict the session tokens, API keys, and passwords it produces next.

Primary Defence: Use secrets module (Python 3.6+) for all security-sensitive random value generation including tokens, passwords, and security keys.

Common Vulnerable Patterns

Using random for Session Token Generation

import random
import string
from flask import Flask, session, request, jsonify

app = Flask(__name__)
app.secret_key = 'dev_secret_key'

def generate_session_token():
    """VULNERABLE - Uses predictable random for session tokens"""
    # Mersenne Twister is predictable after observing enough outputs
    chars = string.ascii_letters + string.digits
    return ''.join(random.choice(chars) for _ in range(32))

@app.route('/login', methods=['POST'])
def login():
    username = request.json.get('username')
    password = request.json.get('password')

    # Authenticate user (simplified)
    if authenticate_user(username, password):
        # VULNERABLE - Weak session token
        session_token = generate_session_token()
        session['token'] = session_token
        session['username'] = username

        return jsonify({
            'status': 'success',
            'session_token': session_token
        })

    return jsonify({'error': 'Invalid credentials'}), 401

def authenticate_user(username, password):
    # Placeholder
    return True

Why this is vulnerable:

  • random.choice() draws from Mersenne Twister, not from a cryptographic generator
  • The PRNG state can be recovered from roughly 624 observed outputs, and every later token follows from it
  • A predicted token is a valid session, so an account is taken over without its credentials

Weak Password Reset Token

import random
import hashlib
from datetime import datetime, timedelta
from django.core.mail import send_mail
from django.http import JsonResponse
from django.views import View

class PasswordResetView(View):
    def post(self, request):
        email = request.POST.get('email')

        # VULNERABLE - Weak reset token generation
        reset_token = self.generate_reset_token()

        # Store token in database
        PasswordResetToken.objects.create(
            email=email,
            token=reset_token,
            expires_at=datetime.now() + timedelta(hours=1)
        )

        # Send email
        send_mail(
            'Password Reset',
            f'Reset token: {reset_token}',
            'noreply@example.com',
            [email]
        )

        return JsonResponse({'status': 'sent'})

    def generate_reset_token(self):
        """VULNERABLE - Weak token generation"""
        # Using time and random is predictable
        timestamp = str(datetime.now().timestamp())
        random_part = str(random.randint(100000, 999999))

        # Even hashing doesn't fix the weak randomness
        token = hashlib.sha256(
            (timestamp + random_part).encode()
        ).hexdigest()

        return token

Why this is vulnerable:

  • random.randint() is predictable, so the value it contributes is predictable
  • Timestamp is guessable (clock synchronization)
  • Hashing does not add entropy - the digest is only as unguessable as the timestamp and randint() value it covers

Weak API Key Generation

import random
import string
from fastapi import FastAPI, HTTPException
from pydantic import BaseModel

app = FastAPI()

class APIKeyRequest(BaseModel):
    user_id: int
    description: str

def generate_api_key():
    """VULNERABLE - Uses random for API key generation"""
    # Weak: random module is not cryptographically secure
    chars = string.ascii_uppercase + string.digits
    prefix = 'sk_'
    key = prefix + ''.join(random.choice(chars) for _ in range(32))
    return key

@app.post("/api/keys")
async def create_api_key(request: APIKeyRequest):
    # VULNERABLE - Weak API key
    api_key = generate_api_key()

    # Store in database
    await db.api_keys.insert({
        'user_id': request.user_id,
        'key': api_key,
        'description': request.description
    })

    return {
        'api_key': api_key,
        'warning': 'Store this key securely'
    }

Why this is vulnerable:

  • random.choice() draws the whole key body, so the key is predictable
  • Observing keys issued to one account recovers the PRNG state and yields the keys issued to others
  • A predicted key authenticates, giving the attacker the victim's API access

Predictable PRNG Seeding

import random
import time
import os

class VulnerableTokenGenerator:
    def __init__(self):
        # VULNERABLE - Seeding with predictable values
        seed = int(time.time()) + os.getpid()
        random.seed(seed)

    def generate_token(self):
        """Generates predictable tokens due to weak seeding"""
        return random.getrandbits(128)

    def generate_password(self, length=12):
        """VULNERABLE - Weak password generation"""
        chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'
        return ''.join(random.choice(chars) for _ in range(length))

# Flask route with vulnerable token generator
from flask import Flask, request, jsonify

app = Flask(__name__)
token_gen = VulnerableTokenGenerator()

@app.route('/api/register', methods=['POST'])
def register():
    username = request.json.get('username')

    # VULNERABLE - Predictable temporary password
    temp_password = token_gen.generate_password()

    # Store user with temp password
    user = create_user(username, temp_password)

    return jsonify({
        'username': username,
        'temporary_password': temp_password,
        'message': 'Please change your password'
    })

def create_user(username, password):
    # Placeholder
    pass

Why this is vulnerable:

  • Time-based seeding is predictable (synchronized clocks)
  • PID is easily guessable
  • Recreating the seed recreates the PRNG state and every value that follows it, including the temporary passwords

Weak CSRF Token Generation

import random
from flask import Flask, render_template, session, request
from flask_wtf.csrf import CSRFProtect

app = Flask(__name__)
app.secret_key = 'secret_key'

def generate_csrf_token():
    """VULNERABLE - Weak CSRF token"""
    # Using random instead of secrets
    token = ''.join(
        random.choice('0123456789abcdef') for _ in range(32)
    )
    return token

@app.route('/form')
def show_form():
    # VULNERABLE - Weak CSRF token
    csrf_token = generate_csrf_token()
    session['csrf_token'] = csrf_token

    return render_template('form.html', csrf_token=csrf_token)

@app.route('/submit', methods=['POST'])
def submit_form():
    user_token = request.form.get('csrf_token')
    session_token = session.get('csrf_token')

    if user_token != session_token:
        return 'CSRF token invalid', 403

    # Process form
    return 'Success'

Why this is vulnerable:

  • CSRF tokens must be unpredictable
  • random lets an attacker predict the token held in the session
  • A predicted token passes the equality check, so a forged cross-site request is accepted

Weak Salt Generation

import random
import hashlib

class VulnerablePasswordHasher:
    def hash_password(self, password):
        """VULNERABLE - Weak salt generation"""
        # WRONG: Using random for salt generation
        salt = str(random.randint(1000000, 9999999))

        # Even with salt, weak randomness compromises security
        salted = salt + password
        hashed = hashlib.sha256(salted.encode()).hexdigest()

        return f"{salt}:{hashed}"

    def verify_password(self, password, stored):
        salt, stored_hash = stored.split(':')
        salted = salt + password
        computed_hash = hashlib.sha256(salted.encode()).hexdigest()
        return computed_hash == stored_hash

# Django view with weak password hashing
from django.http import JsonResponse
from django.views import View

class RegisterView(View):
    def post(self, request):
        username = request.POST.get('username')
        password = request.POST.get('password')

        hasher = VulnerablePasswordHasher()
        # VULNERABLE - Weak salt means rainbow tables still work
        password_hash = hasher.hash_password(password)

        # Store in database
        User.objects.create(
            username=username,
            password_hash=password_hash
        )

        return JsonResponse({'status': 'registered'})

Why this is vulnerable:

  • Salt with only 7 digits (9 million possibilities)
  • A salt that small can be pre-computed for every value, so rainbow tables still work against the stored hashes

Weak Initialization Vector (IV)

import random
from Crypto.Cipher import AES
from Crypto.Util.Padding import pad, unpad
import base64

class VulnerableEncryption:
    def __init__(self, key):
        self.key = key

    def encrypt(self, plaintext):
        """VULNERABLE - Weak IV generation"""
        # WRONG: Using random for IV generation
        iv = bytes([random.randint(0, 255) for _ in range(16)])

        cipher = AES.new(self.key, AES.MODE_CBC, iv)
        padded = pad(plaintext.encode(), AES.block_size)
        ciphertext = cipher.encrypt(padded)

        # Return IV + ciphertext
        return base64.b64encode(iv + ciphertext).decode()

    def decrypt(self, encrypted):
        data = base64.b64decode(encrypted)
        iv = data[:16]
        ciphertext = data[16:]

        cipher = AES.new(self.key, AES.MODE_CBC, iv)
        decrypted = unpad(cipher.decrypt(ciphertext), AES.block_size)

        return decrypted.decode()

# FastAPI endpoint
from fastapi import FastAPI

app = FastAPI()
encryptor = VulnerableEncryption(b'sixteen_byte_key')

@app.post("/api/encrypt")
async def encrypt_data(data: str):
    # VULNERABLE - Weak IV compromises encryption
    encrypted = encryptor.encrypt(data)
    return {"encrypted": encrypted}

Why this is vulnerable:

  • CBC mode requires an IV the attacker cannot predict
  • A predictable IV lets an attacker who can submit chosen plaintext confirm guesses about data already encrypted under the same key

Weak UUID Generation

import random
import uuid

def generate_weak_uuid():
    """VULNERABLE - Custom UUID generation with weak randomness"""
    # WRONG: Building UUID with random
    random_bytes = bytes([random.randint(0, 255) for _ in range(16)])
    return uuid.UUID(bytes=random_bytes)

def generate_user_id():
    """VULNERABLE - Predictable user IDs"""
    # Using random to generate user IDs
    return f"user_{random.randint(100000, 999999)}"

# Django model with weak IDs
from django.db import models

class User(models.Model):
    # VULNERABLE - Weak UUID as primary key
    id = models.CharField(max_length=36, primary_key=True)
    username = models.CharField(max_length=100)

    @classmethod
    def create_user(cls, username):
        # VULNERABLE - Predictable IDs enable enumeration
        user_id = str(generate_weak_uuid())
        return cls.objects.create(id=user_id, username=username)

Why this is vulnerable:

  • A UUID built from random bytes is guessable, and so is user_ plus a random.randint() value
  • Guessable IDs let an attacker enumerate accounts through any endpoint keyed on them
  • Any code path that treats the ID itself as unguessable is reachable by an attacker who guesses it

Secure Patterns

Using secrets Module for Token Generation

import secrets
import string
from flask import Flask, session, request, jsonify
from datetime import datetime, timedelta

app = Flask(__name__)
app.secret_key = secrets.token_hex(32)

def generate_session_token(length=32):
    """Secure: Uses secrets module for cryptographically secure tokens"""
    # secrets.token_urlsafe() generates URL-safe tokens
    return secrets.token_urlsafe(length)

def generate_session_id():
    """Alternative: Using token_hex for hex tokens"""
    return secrets.token_hex(32)  # 32 bytes = 256 bits

@app.route('/login', methods=['POST'])
def login():
    username = request.json.get('username')
    password = request.json.get('password')

    if authenticate_user(username, password):
        # SECURE - Cryptographically secure session token
        session_token = generate_session_token()

        # Store in database with expiration
        SessionToken.objects.create(
            token=session_token,
            username=username,
            created_at=datetime.now(),
            expires_at=datetime.now() + timedelta(hours=24)
        )

        session['token'] = session_token
        session['username'] = username

        return jsonify({
            'status': 'success',
            'session_token': session_token
        })

    return jsonify({'error': 'Invalid credentials'}), 401

@app.route('/api/verify-session', methods=['POST'])
def verify_session():
    token = request.headers.get('Authorization', '').replace('Bearer ', '')

    session_obj = SessionToken.objects.filter(
        token=token,
        expires_at__gt=datetime.now()
    ).first()

    if not session_obj:
        return jsonify({'error': 'Invalid or expired session'}), 401

    return jsonify({'status': 'valid', 'username': session_obj.username})

def authenticate_user(username, password):
    # Actual authentication logic
    pass

Why this works:

  • secrets module designed for security: Uses os.urandom() internally, not predictable Mersenne Twister like random module
  • OS cryptographic sources: Reads from platform CSPRNG APIs such as getrandom()//dev/urandom on Unix-like systems and CNG on Windows
  • 256 bits defeats guessing: 2^256 possible values leaves an exhaustive search with no realistic chance of hitting a live token
  • URL-safe Base64 encoding: Automatically replaces + with -, / with _, removes = padding for web contexts
  • Suitable for all security tokens: Session tokens, API keys, password reset, CSRF tokens, any unpredictable identifier

Secure Password Reset Token

import secrets
from datetime import datetime, timedelta
from django.core.mail import send_mail
from django.http import JsonResponse
from django.views import View
from django.utils import timezone

class SecurePasswordResetView(View):
    def post(self, request):
        email = request.POST.get('email')

        # SECURE - Cryptographically secure reset token
        reset_token = secrets.token_urlsafe(32)

        # Store token in database with expiration
        PasswordResetToken.objects.create(
            email=email,
            token=reset_token,
            expires_at=timezone.now() + timedelta(hours=1)
        )

        # Send email with reset link
        reset_url = f"https://example.com/reset?token={reset_token}"
        send_mail(
            'Password Reset Request',
            f'Click here to reset your password: {reset_url}',
            'noreply@example.com',
            [email],
            fail_silently=False
        )

        return JsonResponse({'status': 'sent'})

    def verify_and_reset(self, request):
        token = request.POST.get('token')
        new_password = request.POST.get('new_password')

        # Verify token and expiration
        reset_obj = PasswordResetToken.objects.filter(
            token=token,
            expires_at__gt=timezone.now(),
            used=False
        ).first()

        if not reset_obj:
            return JsonResponse({'error': 'Invalid or expired token'}, status=400)

        # Reset password
        user = User.objects.get(email=reset_obj.email)
        user.set_password(new_password)
        user.save()

        # Mark token as used
        reset_obj.used = True
        reset_obj.save()

        return JsonResponse({'status': 'password_reset'})

Why this works:

  • High entropy: secrets.token_urlsafe(32) provides 256 bits entropy - attackers can't guess valid tokens even with email, timing info, or previous tokens
  • One-time use: used=True marking prevents replay attacks where intercepted email exploited multiple times
  • Time-limited exposure: Short expiration (typically 1 hour) creates limited attack window - compromised email becomes worthless quickly
  • Database enforcement: Query filters (expires_at__gt=timezone.now(), used=False) automatically reject stale/consumed tokens during verification
  • Safer than temp passwords: Token doesn't grant access until user proves email ownership by clicking link and actively setting new password

Secure API Key Generation

import secrets
import hashlib
from fastapi import FastAPI, HTTPException, Depends
from pydantic import BaseModel
from datetime import datetime, timezone

app = FastAPI()

class APIKeyRequest(BaseModel):
    user_id: int
    description: str
    permissions: list[str]

class APIKeyService:
    @staticmethod
    def generate_api_key(prefix='sk_live_'):
        """Generate cryptographically secure API key"""
        # Generate 32 bytes of random data
        random_part = secrets.token_urlsafe(32)

        # Add prefix for key identification
        return f"{prefix}{random_part}"

    @staticmethod
    def hash_api_key(api_key: str) -> str:
        """Hash API key for secure storage"""
        # Store hash, not plaintext
        return hashlib.sha256(api_key.encode()).hexdigest()

    @staticmethod
    async def create_api_key(user_id: int, description: str, permissions: list):
        """Create and store API key"""
        # Generate secure key
        api_key = APIKeyService.generate_api_key()

        # Hash for storage
        key_hash = APIKeyService.hash_api_key(api_key)

        # Store in database
        await db.api_keys.insert({
            'user_id': user_id,
            'key_hash': key_hash,  # Store hash, not plaintext
            'description': description,
            'permissions': permissions,
            'created_at': datetime.now(timezone.utc),
            'last_used': None
        })

        # Return plaintext key only once
        return api_key

    @staticmethod
    async def verify_api_key(api_key: str) -> dict:
        """Verify API key and return associated data"""
        key_hash = APIKeyService.hash_api_key(api_key)

        key_record = await db.api_keys.find_one({
            'key_hash': key_hash,
            'revoked': False
        })

        if not key_record:
            return None

        # Update last used timestamp
        await db.api_keys.update({'_id': key_record['_id']}, {
            '$set': {'last_used': datetime.now(timezone.utc)}
        })

        return key_record

@app.post("/api/keys")
async def create_api_key(request: APIKeyRequest):
    """Create new API key"""
    api_key = await APIKeyService.create_api_key(
        request.user_id,
        request.description,
        request.permissions
    )

    return {
        'api_key': api_key,
        'warning': 'Store this key securely. It will not be shown again.'
    }

@app.get("/api/protected")
async def protected_endpoint(api_key: str = Depends(get_api_key_from_header)):
    """Protected endpoint requiring API key"""
    key_record = await APIKeyService.verify_api_key(api_key)

    if not key_record:
        raise HTTPException(status_code=401, detail="Invalid API key")

    return {"message": "Access granted", "user_id": key_record['user_id']}

def get_api_key_from_header(authorization: str = Header(None)):
    if not authorization or not authorization.startswith('Bearer '):
        raise HTTPException(status_code=401, detail="Missing API key")
    return authorization.replace('Bearer ', '')

Why this works:

  • High entropy: secrets.token_urlsafe(32) above asks for 32 bytes and returns a 43-character URL-safe string carrying 256 bits - the argument is a byte count, not a character count, which is the usual place this gets misread. 256 bits is unpredictable and collision-free past any number of keys you will issue
  • Hash storage: Stores SHA-256 hashes (not plaintext) via hashlib.sha256() - if database compromised, attackers get irreversible hashes
  • One-way verification: hash_api_key() allows authentication by hashing provided key and comparing to stored hash without storing original
  • One-time display: Returns plaintext key only during creation; enforces "copy now or lose forever" - no "show me my key again" functionality
  • Conventional naming: sk_live_ prefix (Stripe-style) makes keys identifiable in logs and enables secret scanning tools to detect accidental commits

Secure CSRF Token with Django

from django.middleware.csrf import get_token
from django.views.decorators.csrf import csrf_protect
from django.http import JsonResponse
from django.shortcuts import render

@csrf_protect
def show_form(request):
    """Django automatically generates secure CSRF tokens"""
    # Django uses secrets module internally for CSRF tokens
    csrf_token = get_token(request)

    return render(request, 'form.html', {
        'csrf_token': csrf_token
    })

@csrf_protect
def submit_form(request):
    """CSRF protection is automatic with decorator"""
    if request.method == 'POST':
        # Django validates CSRF token automatically
        # Process form data
        return JsonResponse({'status': 'success'})

    return JsonResponse({'error': 'Method not allowed'}, status=405)

# For API endpoints with custom CSRF handling
import secrets

class CustomCSRFProtection:
    @staticmethod
    def generate_csrf_token():
        """Generate secure CSRF token"""
        return secrets.token_urlsafe(32)

    @staticmethod
    def verify_csrf_token(token: str, stored_token: str) -> bool:
        """Timing-safe CSRF token verification"""
        if not token or not stored_token:
            return False

        # Use secrets.compare_digest for timing-safe comparison
        return secrets.compare_digest(token, stored_token)

Why this works:

  • Framework integration: Django uses secrets module internally for cryptographically strong CSRF tokens preventing Cross-Site Request Forgery
  • Automatic injection: {% csrf_token %} template tag inserts hidden field with unique, unpredictable token for all state-changing requests (POST/PUT/DELETE)
  • Timing-safe validation: Middleware validates with constant_time_compare() preventing timing attacks where attackers infer token characters via response time measurements
  • Session binding: Token bound to user's session (session storage or signed cookie); only readable by same-origin JavaScript prevents cross-site theft
  • Built-in protection: Enabled by default in middleware stack; @csrf_protect enforces on views, @csrf_exempt allows opt-out; auto-rotates after login preventing session fixation

Secure Salt and IV Generation

import os
import secrets
import hashlib
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC
from cryptography.hazmat.primitives import hashes
import base64

class SecurePasswordHasher:
    @staticmethod
    def hash_password(password: str) -> str:
        """Hash password with secure salt"""
        # Generate cryptographically secure salt
        salt = secrets.token_bytes(32)  # 256 bits

        # PBKDF2HMAC is the class name; there is no `PBKDF2` in this module
        kdf = PBKDF2HMAC(
            algorithm=hashes.SHA256(),
            length=32,
            salt=salt,
            iterations=600_000
        )

        key = kdf.derive(password.encode())

        # Encode salt and hash for storage
        salt_b64 = base64.b64encode(salt).decode()
        key_b64 = base64.b64encode(key).decode()

        return f"pbkdf2_sha256$600000${salt_b64}${key_b64}"

    @staticmethod
    def verify_password(password: str, stored: str) -> bool:
        """Verify password against stored hash"""
        parts = stored.split('$')
        iterations = int(parts[1])
        salt = base64.b64decode(parts[2])
        stored_key = base64.b64decode(parts[3])

        # A KDF instance is single-use - derive() raises AlreadyFinalized on a
        # second call - so verification builds a fresh one with the stored salt
        # and iteration count.
        kdf = PBKDF2HMAC(
            algorithm=hashes.SHA256(),
            length=32,
            salt=salt,
            iterations=iterations
        )

        computed_key = kdf.derive(password.encode())

        # Timing-safe comparison
        return secrets.compare_digest(computed_key, stored_key)

class SecureEncryption:
    def __init__(self, key: bytes):
        self.key = key

    @staticmethod
    def generate_key():
        """Generate secure encryption key"""
        return secrets.token_bytes(32)  # 256 bits for AES-256

    def encrypt(self, plaintext: str) -> str:
        """Encrypt with secure IV"""
        # Generate cryptographically secure nonce/IV
        nonce = secrets.token_bytes(12)  # 96 bits for GCM

        aesgcm = AESGCM(self.key)
        ciphertext = aesgcm.encrypt(nonce, plaintext.encode(), None)

        # Combine nonce and ciphertext
        combined = base64.b64encode(nonce + ciphertext).decode()
        return combined

    def decrypt(self, encrypted: str) -> str:
        """Decrypt data"""
        combined = base64.b64decode(encrypted)
        nonce = combined[:12]
        ciphertext = combined[12:]

        aesgcm = AESGCM(self.key)
        plaintext = aesgcm.decrypt(nonce, ciphertext, None)

        return plaintext.decode()

# Flask application using secure encryption
from flask import Flask, request, jsonify

app = Flask(__name__)

# Master key injected at start-up from a secret store - not stored in the environment (CWE-526)
MASTER_KEY = base64.b64decode(os.environ.get('MASTER_ENCRYPTION_KEY'))
encryptor = SecureEncryption(MASTER_KEY)

@app.route('/api/encrypt', methods=['POST'])
def encrypt_data():
    data = request.json.get('data')

    # SECURE - Uses secrets for IV generation
    encrypted = encryptor.encrypt(data)

    return jsonify({'encrypted': encrypted})

Why this works:

  • Unique salts: secrets.token_bytes(32) ensures 256-bit salt uniqueness per password - even identical passwords get different hashes, preventing rainbow tables and forcing per-hash cracking
  • Salt properties: Doesn't need secrecy (stored with hash) but requires uniqueness and randomness
  • Nonce uniqueness critical: secrets.token_bytes(12) gives the 96-bit GCM nonce, which must never repeat under the same key - reuse leaks the XOR of the two plaintexts and exposes the GHASH authentication key, so forgery follows loss of confidentiality. The nonce is prepended to the ciphertext because decryption needs it; it must be unique, not secret
  • Timing-safe verification: secrets.compare_digest() takes constant time regardless of match, preventing timing attacks during login
  • Authenticated encryption: AES-GCM produces a 128-bit authentication tag, so ciphertext altered in transit fails to decrypt rather than yielding altered plaintext

Secure UUID Generation

import uuid
import secrets

def generate_secure_uuid():
    """Generate UUID4 (uses os.urandom internally)"""
    return uuid.uuid4()

def generate_secure_id():
    """Alternative: Custom secure ID generation"""
    return secrets.token_urlsafe(16)

# Django model with secure UUIDs
from django.db import models
import uuid

class User(models.Model):
    # SECURE - UUID4 uses cryptographically secure random
    id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
    username = models.CharField(max_length=100, unique=True)
    email = models.EmailField(unique=True)
    created_at = models.DateTimeField(auto_now_add=True)

    def __str__(self):
        return self.username

class APIKey(models.Model):
    # SECURE - Custom secure ID
    key_id = models.CharField(max_length=32, primary_key=True)
    user = models.ForeignKey(User, on_delete=models.CASCADE)

    @classmethod
    def create_api_key(cls, user):
        key_id = secrets.token_urlsafe(16)
        return cls.objects.create(key_id=key_id, user=user)

Why this works:

  • Strong entropy: Uses os.urandom() internally for 122 bits of randomness (6 bits reserved for version/variant) - 2^122 possible values prevents collisions and brute-force
  • Prevents enumeration: Unpredictability stops attackers from incrementing sequential IDs to discover resources (unlike auto-incrementing integers)
  • Ideal use cases: Public REST API identifiers (/api/users/{uuid}), database primary keys exposed externally, job/task IDs, file upload identifiers
  • Universal compatibility: Standardized format (8-4-4-4-12 hex) recognized by databases (PostgreSQL native type), APIs, programming languages
  • Security trade-off: For session tokens/API secrets, prefer 256-bit secrets.token_urlsafe(); UUIDs balance security, uniqueness, and interoperability

Secure Random for Security Operations

import secrets
import os
from datetime import datetime, timedelta, timezone

class SecureRandomService:
    """Service for all cryptographically secure random operations"""

    @staticmethod
    def generate_token(nbytes=32):
        """Generate random token (URL-safe base64)"""
        return secrets.token_urlsafe(nbytes)

    @staticmethod
    def generate_hex_token(nbytes=32):
        """Generate hex token"""
        return secrets.token_hex(nbytes)

    @staticmethod
    def generate_bytes(nbytes=32):
        """Generate random bytes"""
        return secrets.token_bytes(nbytes)

    @staticmethod
    def generate_password(length=16, use_symbols=True):
        """Generate secure random password"""
        import string

        alphabet = string.ascii_letters + string.digits
        if use_symbols:
            alphabet += string.punctuation

        # Use secrets.choice() instead of random.choice()
        password = ''.join(secrets.choice(alphabet) for _ in range(length))

        # Ensure password meets complexity requirements
        has_upper = any(c.isupper() for c in password)
        has_lower = any(c.islower() for c in password)
        has_digit = any(c.isdigit() for c in password)

        if not (has_upper and has_lower and has_digit):
            # Regenerate if doesn't meet requirements
            return SecureRandomService.generate_password(length, use_symbols)

        return password

    @staticmethod
    def generate_otp(length=6):
        """Generate numeric OTP"""
        # Use secrets.randbelow() for random integers
        otp = ''.join(str(secrets.randbelow(10)) for _ in range(length))
        return otp

    @staticmethod
    def select_random_item(items):
        """Securely select random item from list"""
        return secrets.choice(items)

# FastAPI application using secure random
from fastapi import FastAPI, HTTPException
from pydantic import BaseModel

app = FastAPI()
random_service = SecureRandomService()

class PasswordResetRequest(BaseModel):
    email: str

@app.post("/api/reset-password")
async def request_password_reset(request: PasswordResetRequest):
    # SECURE - Generate OTP with secrets
    otp = random_service.generate_otp(6)

    # Store OTP with expiration
    await db.otps.insert({
        'email': request.email,
        'otp': otp,
        'created_at': datetime.now(timezone.utc),
        'expires_at': datetime.now(timezone.utc) + timedelta(minutes=10)
    })

    # Send OTP via email
    send_otp_email(request.email, otp)

    return {"message": "OTP sent to email"}

@app.post("/api/generate-temp-password")
async def generate_temp_password():
    # SECURE - Generate temporary password with secrets
    temp_password = random_service.generate_password(16, use_symbols=True)

    return {
        "temporary_password": temp_password,
        "expires_in": "24 hours"
    }

Why this works:

  • Use-case optimized: token_bytes(n) for raw bytes (keys, salts, IVs), token_hex(n) for human-readable hex (2n chars), token_urlsafe(n) for URL-safe Base64 (URLs, headers, cookies), randbelow(n) for integers [0, n)
  • Consistent security: All methods use os.urandom() internally, ensuring cryptographic strength regardless of convenience function
  • Drop-in replacement: SystemRandom class uses os.urandom(), allowing code using random.choice() or random.shuffle() to upgrade by changing random instance

Considerations

Decide whether the value is security-relevant. The random module is the right tool for simulation, sampling, test fixtures, jitter, and shuffling a display order, and a finding against one of those should be closed with the reason recorded. It is a genuine finding when the output becomes a session ID, a password-reset or email-verification token, an API key, a CSRF token, a salt, an encryption IV or nonce, an OTP, a coupon code with value, or any filename an attacker must not guess.

The generator and the length are separate decisions. OWASP and NIST SP 800-63B put the floor for a session token at 64 bits of entropy; in practice use at least 16 bytes (128 bits) for tokens and 32 for key material. A CSPRNG asked for four bytes still yields a brute-forceable value.

Testing

  • Compare tokens with secrets.compare_digest() rather than == anywhere a token is checked, so the comparison does not leak position information through timing.
  • Generate a large batch and assert every value is distinct, of the expected length, and shares no common prefix. A shared prefix usually means a counter or timestamp was concatenated onto the random part, which reduces the effective entropy to whatever the random part alone provides.
  • Confirm the values do not correlate with generation time. Restarting the process must not reproduce an earlier sequence, which is the observable symptom of seeding that survived the fix.
  • Re-run the scanner that reported the finding and confirm it no longer triggers.

Common Pitfalls

  • Seeding random with strong entropy but still using random: Calling random.seed(os.urandom(32)) before generating tokens, assuming a "good" seed makes the module safe. random's Mersenne Twister state (624 32-bit words) can be reconstructed from roughly 624 consecutive outputs regardless of how it was seeded; once reconstructed, every past and future value it produces becomes predictable.
  • Using uuid.uuid1() where an unguessable identifier is required: uuid1() encodes the host's MAC address and the current timestamp, not randomness - it looks like a modern, "secure-sounding" identifier but is highly predictable. Only uuid.uuid4() (or secrets) provides cryptographic unpredictability.
  • Mixing a secure token with a weak "extra" component: For example secrets.token_hex(16) + str(random.randint(0, 9)) to pad a token to a round length. The weak suffix doesn't add real entropy, but if any part of the system treats that appended digit as meaningful, it creates a false sense of extra strength.

Additional Resources