Safeguard
Industry Analysis

Secure Random Number Generation in Python with the secret...

Python's random module is predictable, not secure. Here's why CWE-338 matters, when the secrets module (PEP 506, Python 3.6) fixed it, and how to generate tokens safely.

Aman Khan
AppSec Engineer
7 min read

A password-reset feature that calls random.randint(100000, 999999) for a six-digit code looks fine in a pull request and fine in a demo. It is not fine in production. Python's default random module is built on the Mersenne Twister algorithm — a fast, high-quality statistical PRNG with a 2^19937-1 period, but one that is fully deterministic and, critically, invertible: recover 624 consecutive 32-bit outputs and you can reconstruct the entire internal state and predict every value the generator will ever produce again. That is precisely why the Python documentation has, for years, carried an explicit warning not to use random for security purposes, why static analyzers flag it as CWE-338 ("Use of Cryptographically Weak PRNG"), and why the secrets module exists at all. This post explains what actually goes wrong, when the fix arrived, and how to generate tokens, passwords, and API keys correctly.

Why isn't Python's random module safe for tokens and passwords?

Because it was never designed to be unpredictable — it was designed to be fast and statistically uniform for simulations, games, and sampling. The Mersenne Twister core (MT19937) maintains a 624-word (19,968-bit) internal state array, and there is published, well-known cryptanalysis showing that observing 624 consecutive getrandbits(32) outputs is sufficient to fully clone the generator's state and forecast all subsequent "random" values with certainty. In practice this means anything derived from random — a session identifier, a password-reset code, a CSRF token, an API key — can potentially be predicted by an attacker who can observe enough output or brute-force a small search space. Static analysis tooling has formalized this risk: Bandit, the widely used Python SAST tool, flags this exact pattern under rule B311 ("Standard pseudo-random generators are not suitable for security/cryptographic purposes"), and it maps directly to CWE-338 in the MITRE weakness taxonomy. If a scanner in your CI pipeline has ever raised a B311 finding, this is the underlying reason.

What is the secrets module, and when did Python get it?

It's the standard library's purpose-built answer to exactly this problem, added via PEP 506, authored by Steven D'Aprano, targeted at and shipped in Python 3.6, released December 23, 2016. Before PEP 506, developers who knew better had to reach for os.urandom() or random.SystemRandom directly — both of which pull entropy from the operating system's cryptographically secure random source (the getrandom() syscall on Linux, /dev/urandom, or BCryptGenRandom on Windows) rather than a software PRNG. The secrets module wraps that same OS-backed CSPRNG behind a small, hard-to-misuse API: secrets.token_bytes(), secrets.token_hex(), secrets.token_urlsafe(), and secrets.choice(). The design goal, stated plainly in the PEP, was to give developers "an obvious way to do the right thing" so that security-sensitive code stopped depending on whether the author happened to know that random was unsafe.

How do you actually generate secure tokens, passwords, and API keys?

Concretely: use secrets.token_urlsafe(32) for session and API tokens, and secrets.choice() over a defined character set for human-facing passwords — not random.choice(). A few patterns cover almost every real case:

import secrets
import string

# A 256-bit URL-safe token (32 bytes -> ~43 characters), suitable for
# session identifiers, password-reset links, or API keys
token = secrets.token_urlsafe(32)

# A 128-bit hex token, common for CSRF tokens or short-lived codes
csrf_token = secrets.token_hex(16)

# A 12-character password drawn from letters, digits, and punctuation
alphabet = string.ascii_letters + string.digits + string.punctuation
password = ''.join(secrets.choice(alphabet) for _ in range(12))

# A numeric one-time code — still cryptographically sourced,
# not random.randint()
otp = f"{secrets.randbelow(1_000_000):06d}"

The entropy math matters for compliance and threat modeling: token_urlsafe(32) draws 32 raw bytes from the OS CSPRNG before base64-encoding, giving 256 bits of entropy — comfortably above the 128-bit minimum that NIST SP 800-63B recommends for high-assurance secrets, and far beyond what a six-digit random.randint() code (roughly 20 bits, one of only 900,000 possibilities) provides.

What real-world incidents show why this isn't theoretical?

The canonical cautionary tale is the Debian OpenSSL vulnerability, CVE-2008-0166, disclosed in May 2008: a 2006 packaging change accidentally stripped the entropy-gathering code from Debian's OpenSSL build, leaving the PRNG seeded almost entirely by the process ID — a value with only about 32,768 possible states. For roughly two years, every SSH key, SSL certificate, and OpenVPN key generated on affected Debian and Ubuntu systems was drawn from a tiny, enumerable keyspace, and researchers published the complete set of weak keys within days of disclosure. It remains one of the most cited examples in security training precisely because it shows how a single bad-randomness decision, undetected for years, can silently compromise every credential a system ever issues. Python's own ecosystem has produced smaller-scale but analogous findings: supply-chain and SAST scans routinely surface random-based token generation in internal tooling, test fixtures that leak into production paths, and homegrown authentication helpers — the exact CWE-338 pattern Bandit's B311 rule exists to catch before it ships.

How does secrets.compare_digest prevent a second, subtler bug?

It stops timing attacks by comparing two values in constant time instead of Python's default short-circuit comparison. Even after a token is generated securely, comparing it with a plain == check (or token == stored_token) can leak information: Python's string comparison typically exits as soon as it finds a mismatched byte, so the time taken to reject a guess correlates with how many leading bytes were correct. Over many network requests, an attacker can exploit that timing signal to reconstruct a valid token byte-by-byte — a real, documented class of attack cataloged as CWE-208 ("Observable Timing Discrepancy"). secrets.compare_digest(a, b) (an alias for hmac.compare_digest) always examines the full length of both inputs regardless of where they diverge, removing that side channel. Any code that validates a token, API key, HMAC signature, or password hash should use it instead of ==.

How can a team catch insecure randomness before it reaches production?

By enforcing static analysis in CI rather than relying on individual developers to remember the rule, since Bandit's B311 check and equivalent Semgrep rules can flag every random.* call used in a security context automatically, before merge. In practice this means: running Bandit or Semgrep on every pull request with B311/CWE-338 rules enabled and set to fail the build; adding a lint rule or pre-commit hook that blocks import random in files under auth/, tokens/, or crypto/ paths; and including "does this generate a token, key, password, or nonce?" as an explicit checklist item in code review for any new authentication or session-handling code. Because this class of bug is easy to introduce (the random module is imported constantly for legitimate, non-security uses like shuffling test data or sampling) and easy to miss in review, automated detection is the only approach that scales across a codebase with dozens of contributors and years of accumulated code.

How Safeguard Helps

This is a textbook example of the gap between "the code runs" and "the code is safe" — and it's exactly the gap Safeguard is built to close across the software supply chain. Safeguard's static analysis and code-scanning pipeline detects CWE-338-class weak-randomness patterns — insecure use of random, predictable token generation, and non-constant-time comparisons — directly in first-party code and in the open-source dependencies your services pull in, before they reach production. Beyond a single file, Safeguard correlates these findings against your software bill of materials (SBOM) so you can see at a glance which services, packages, and transitive dependencies rely on weak randomness for tokens, keys, or credentials — turning a scattered set of random.choice() calls into a prioritized, auditable remediation list. For teams under SOC 2, ISO 27001, or NIST-aligned compliance programs, that traceability matters as much as the fix itself: Safeguard gives you the evidence trail showing the weakness was identified, tracked, and resolved, not just quietly patched. Combined with policy gates that block merges introducing new CWE-338 findings, Safeguard turns "use secrets, not random" from a code-review reminder into an enforced, continuously verified standard across every repository in your supply chain.

Never miss an update

Weekly insights on software supply chain security, delivered to your inbox.