Cryptographically Secure Password Generation in Python: random vs secrets
Entropy defines password quality not as a property of the string itself, but as a characteristic of the generation process. The formula H = L × log₂(N) assumes an independent and equally probable choice of each character from an alphabet of size N over a length L. For 8 lowercase letters (N=26): ~37.6 bits. For full ASCII (N=94): ~79 bits.
A common misconception: the apparent randomness of a password guarantees its strength. Two passwords like f9A$kL2pQzX1 can have the same formal entropy but vastly different real-world security depending on the generator used.
The random module: a deterministic PRNG
The random module implements the Mersenne Twister (MT19937) with an internal state of ~19937 bits. Each value is deterministic based on the previous one. The default seed is taken from os.urandom(), but the PRNG's inherent properties remain:
- Determinism: one seed yields one predictable sequence.
- Recoverability: the state can be reconstructed from ~624 raw 32-bit outputs.
- Lack of cryptographic security: not resistant to state-recovery attacks.
Example of typical code:
import random
import string
alphabet = string.ascii_letters + string.digits
def generate_password(length=12):
return ''.join(random.choice(alphabet) for _ in range(length))
The password is a slice of the PRNG sequence. If the state is known, the sequence is predictable.
The secrets module: direct access to system entropy
Secrets uses os.urandom() directly for each call:
- secrets.randbelow(n) → os.urandom().
- random.SystemRandom() → os.urandom().
There is no fixed state, no determinism. Each byte is drawn from hardware sources: interrupts, network noise, mouse movements.
The difference is critical: random creates an illusion of security, secrets provides it.
Practical attack: recovering seed and state
Experiment with 5 consecutive passwords from GigaChat (length 12, Python 3.9.18, time 24.03.2026 11:11):
Hypothesis: seed = server timestamp ±1 hour.
from datetime import datetime, timedelta
base_time = datetime(2026, 3, 24, 11, 11, 0)
window_seconds = 3600
for offset in range(-window_seconds, window_seconds):
t = base_time + timedelta(seconds=offset)
seed = int(t.timestamp())
# Initialize random.seed(seed) and verify
Result: no matches. Using os.urandom() for the seed defeats brute-force time-based attacks.
A real attack on MT19937 requires 624 raw values. Passwords are a distorted output (choice → character), totaling only 60 choices for 5 passwords. Recovery is impossible.
Attack boundaries:
- Requires raw PRNG outputs.
- Predictable seed (time, PID).
- Repeated use of the same generator.
- Known algorithm.
Key takeaways
- Entropy is a property of the process, not the result: always verify your generator.
- random (Mersenne Twister) is vulnerable to state recovery given sufficient data.
- secrets/os.urandom is the only choice for passwords, keys, and tokens.
- The illusion of randomness from random is dangerous in production.
- PRNG attacks are only realistic with seed errors or state leaks.
Recommendations for middle/senior developers
Implement in your code:
import secrets
import string
alphabet = string.ascii_letters + string.digits + string.punctuation
def secure_password(length=16):
return ''.join(secrets.choice(alphabet) for _ in range(length))
Avoid:
- Using random for authentication.
- Fixed seeds.
- Reusing generators.
Test: generate 10⁶ passwords, check for uniform distribution (chi²).
— Editorial Team
No comments yet.