Password reset tokens built with Python random are predictable: switch to secrets
A security review found that our password reset tokens and temporary passwords came from Python's random module. Its Mersenne Twister generator is fast and statistically good, but not unpredictable. We moved to secrets, hashed tokens at rest, and added Bandit to CI.
The finding came from an external security review of our customer portal, not from an outage. The reviewer's note was short: password reset tokens and the temporary passwords sent to newly invited users were generated with Python's random module, which is not a cryptographically secure generator. They rated it high severity because a reset token is a bearer credential for the account.
The code had been there since the first version of the portal:
import random
import string
ALPHABET = string.ascii_letters + string.digits
def make_reset_token(length=12):
return "".join(random.choice(ALPHABET) for _ in range(length))
def make_temp_password():
return "".join(random.choices(ALPHABET, k=10))
The token was stored in plain text in a password_resets table with a 24-hour expiry, and looked up with WHERE token = %s.
Reproducing it
There was nothing to "reproduce" in the crash sense. We confirmed the claim the way the reviewer suggested: with the source available, anyone who can see enough outputs of the same generator instance can, in principle, reconstruct its internal state and compute the next outputs. Our signup flow returned a random-looking invite code from the same process-wide generator, so an attacker could collect outputs simply by signing up repeatedly. We did not build an exploit; the documented properties of the generator were enough to accept the finding.
What we ruled out
- "The token is long enough." Length does not help if the values are predictable. 62^12 is a large space only when each character is chosen unpredictably.
- "It is seeded from the OS."
randomseeds itself fromos.urandomat import, so the starting state is unknown, but the outputs after that are a deterministic function of the state. Seeding protects the first value, not the sequence. - "Each worker process has its own generator." True, but each Gunicorn worker serves thousands of requests, so plenty of outputs come from the same instance.
Root cause
The random module uses the Mersenne Twister (MT19937). It is designed for simulations and sampling: fast, long period, good statistical distribution. It is not designed to resist prediction. Its 19,937-bit state can be recovered from 624 consecutive 32-bit outputs, after which every future output is known. Python's documentation says plainly that this module should not be used for security purposes and points to secrets.
What is happening under the hood
There are two kinds of generator in a typical runtime:
| Kind | Python API | Source | Use for |
|---|---|---|---|
| Pseudo-random (PRNG) | random.random, random.choice, random.randint |
Mersenne Twister in user space | simulations, sampling, shuffling test data, jitter |
| Cryptographically secure (CSPRNG) | secrets.token_bytes, secrets.token_urlsafe, secrets.choice, os.urandom |
the OS generator (getrandom() on Linux) |
tokens, passwords, keys, nonces, session IDs |
A CSPRNG guarantees that seeing any number of outputs gives no useful information about the others. Mersenne Twister gives no such guarantee: each output is a linear function of the state, so observing outputs leaks the state.
random.choice on a 62-character alphabet does not hand out raw 32-bit words, which makes recovery more work in practice, but "more work" is not a security property. The same generator also feeds anything else in the process that calls random, which widens how much of its output an attacker might observe.
The storage choice made it worse. Plain-text tokens in the database mean anyone with read access to that table, or to a backup, can reset any account that has a pending reset.
Environment
| Component | Version / value |
|---|---|
| OS | Ubuntu 22.04 LTS containers |
| Runtime | CPython 3.11.9 |
| Framework | Flask 3.0, Gunicorn 22 |
| Database | PostgreSQL 15 |
| Token storage (before) | plain text, password_resets.token VARCHAR(12) |
| Static analysis (before) | none for security rules |
Minimal reproduction
The core issue is visible without any attack code: the generator is deterministic once its state is fixed.
import random
state = random.getstate()
a = [random.choice("abcdef0123456789") for _ in range(8)]
random.setstate(state)
b = [random.choice("abcdef0123456789") for _ in range(8)]
print(a == b) # True: same state, same "random" token
Anyone who learns the state, whether by reading memory or by inferring it from outputs, gets the same tokens your server will issue next.
Who is affected
Any code that uses random for something an attacker should not be able to guess: reset tokens, invite codes, temporary passwords, API keys, session IDs, OTP codes, file names for private uploads, or CSRF tokens. The same applies in other languages: Math.random() in JavaScript, java.util.Random, rand() in PHP and C.
The fix
1. Generate with secrets. For tokens sent in URLs we use 32 bytes of randomness encoded as URL-safe base64:
import secrets
import string
ALPHABET = string.ascii_letters + string.digits
def make_reset_token() -> str:
return secrets.token_urlsafe(32) # 256 bits, about 43 characters
def make_temp_password(length: int = 16) -> str:
return "".join(secrets.choice(ALPHABET) for _ in range(length))
2. Store only a hash of the token. The email contains the token; the database stores its SHA-256 digest. A high-entropy random token does not need a slow password hash; a fast hash is enough because brute force over 256 bits is infeasible.
import hashlib
from datetime import datetime, timedelta, timezone
def token_digest(token: str) -> str:
return hashlib.sha256(token.encode("ascii")).hexdigest()
def create_reset(db, user_id: int) -> str:
token = make_reset_token()
db.execute(
"INSERT INTO password_resets (user_id, token_sha256, expires_at) "
"VALUES (%s, %s, %s)",
(user_id, token_digest(token), datetime.now(timezone.utc) + timedelta(minutes=30)),
)
return token
def consume_reset(db, token: str) -> int | None:
row = db.execute(
"DELETE FROM password_resets "
"WHERE token_sha256 = %s AND expires_at > now() "
"RETURNING user_id",
(token_digest(token),),
).fetchone()
return row[0] if row else None
Looking up by digest means there is no comparison of secret strings in Python, so timing differences in string comparison are not a concern. DELETE ... RETURNING makes the token single-use atomically. If you ever compare secrets directly, use hmac.compare_digest.
3. Shorten lifetime and invalidate on use. Expiry went from 24 hours to 30 minutes, and a successful reset deletes every pending reset for that user and revokes existing sessions.
4. Replace temporary passwords with links. Invited users now get a one-time setup link built the same way, so we no longer email a usable password at all. The make_temp_password helper survives only for an internal admin tool.
The schema migration:
ALTER TABLE password_resets ADD COLUMN token_sha256 CHAR(64);
DELETE FROM password_resets; -- invalidate every outstanding plain-text token
ALTER TABLE password_resets DROP COLUMN token;
ALTER TABLE password_resets ALTER COLUMN token_sha256 SET NOT NULL;
CREATE UNIQUE INDEX password_resets_token_sha256_idx ON password_resets (token_sha256);
Alternatives and trade-offs
random.SystemRandom()usesos.urandomand is secure, and it is whatsecretsbuilds on.secretsis clearer about intent, so we standardised on it.- Signed, stateless tokens (for example
itsdangerouswith a timestamp). They avoid a database table but are hard to revoke individually, and invalidating on password change needs extra state anyway. We kept the table. - Keeping
randomfor non-security uses. We did: retry jitter, test fixtures and sampling still userandom. The rule is about purpose, not a blanket ban.
How to test the fix
Unit tests check format, uniqueness and single use:
def test_reset_token_entropy_and_format():
tokens = {make_reset_token() for _ in range(10_000)}
assert len(tokens) == 10_000
assert all(len(t) >= 43 for t in tokens)
def test_reset_token_is_single_use(db, user):
token = create_reset(db, user.id)
assert consume_reset(db, token) == user.id
assert consume_reset(db, token) is None
def test_plain_token_not_stored(db, user):
token = create_reset(db, user.id)
stored = db.execute("SELECT token_sha256 FROM password_resets").fetchone()[0]
assert token not in stored
And the static check that would have caught the original code:
pip install bandit
bandit -r portal/ -t B311
>> Issue: [B311:blacklist] Standard pseudo-random generators are not suitable for security/cryptographic purposes.
Severity: Low Confidence: High
Location: portal/auth/tokens.py:8:19
Confirming in production
After deploy we requested a reset for a test account and checked the email link and the database row:
SELECT user_id, length(token_sha256), expires_at - created_at AS ttl
FROM password_resets
ORDER BY created_at DESC
LIMIT 1;
user_id | length | ttl
---------+--------+----------
10234 | 64 | 00:30:00
The link token was 43 characters, the stored value was a 64-character hex digest, and reusing the link returned the "link expired or already used" page. The reviewer re-tested and closed the finding.
Regression guard
- Bandit runs in CI on every pull request with B311 enabled; a finding fails the build in the
auth,accountsandapipackages. - Ruff's
S311rule (the same check) runs in pre-commit for faster feedback. - A short note in our secure coding guide: tokens, passwords, keys and IDs that grant access come from
secrets, nothing else.
Security implications
The original bug allowed, in principle, account takeover through predicted reset tokens, and plain-text storage exposed pending resets to anyone with database or backup read access. We found no evidence of exploitation: reset logs showed no unusual volume or resets requested from one IP for many accounts. As a precaution we invalidated every outstanding reset token and temporary password during the migration and forced a password change for accounts still using a temporary password. The new design reduces exposure further: tokens are unguessable, short-lived, single-use and useless if the table leaks.
Prevention checklist
- Use
secretsfor anything that grants access;randomonly for non-security randomness. - Store hashes of reset and invite tokens, never the tokens.
- Make tokens single-use and short-lived, and revoke sessions after a reset.
- Enable Bandit B311 or Ruff S311 in CI.
- Prefer one-time links over emailed passwords.
References
- Python
secretsmodule: https://docs.python.org/3/library/secrets.html - Python
randommodule (security warning): https://docs.python.org/3/library/random.html - OWASP Forgot Password Cheat Sheet: https://cheatsheetseries.owasp.org/cheatsheets/Forgot_Password_Cheat_Sheet.html
- Bandit B311: https://bandit.readthedocs.io/en/latest/blacklists/blacklist_calls.html
- Ruff rule S311: https://docs.astral.sh/ruff/rules/suspicious-non-cryptographic-random-usage/
Add a label
Embed this post
<iframe src="https://www.hibug.me/python-random-passwords-insecure-use-secrets" width="700" height="420" frameborder="0" loading="lazy"></iframe>
I am a Senior Software Engineer with over a decade of hands-on experience building, scaling, and maintaining complex systems across the full stack—spanning frontend, backend, and DevOps. Over the last 10 years, I have navigated diverse architectures and untangled some of the most frustrating, elusive bugs in production. I believe that every production incident is a massive learning opportunity. Having discovered hibug.me, I am excited to contribute to this knowledge base, breaking down root causes, and sharing verified fixes to help fellow developers debug faster and build more resilient systems.
Comments
0No comments yet. Be the first to comment!
Sign In to post a comment.