Programming Languages Bug Report

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.

John Nguyen John Nguyen @devbugs · April 16, 2026 · 9 min read · 45 Views
Banner: a dice-shaped token generator whose next outputs are visible in a pattern, beside a sealed vault

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." random seeds itself from os.urandom at 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() uses os.urandom and is secure, and it is what secrets builds on. secrets is clearer about intent, so we standardised on it.
  • Signed, stateless tokens (for example itsdangerous with 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 random for non-security uses. We did: retry jitter, test fixtures and sampling still use random. 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, accounts and api packages.
  • Ruff's S311 rule (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 secrets for anything that grants access; random only 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

Add a label

#python #authentication #secrets #random #bandit
Written by
John Nguyen @devbugs

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.

Advertisement

Comments

0

No comments yet. Be the first to comment!

Sign In to post a comment.