Password generator in Python: random against secrets

One module separates these, and the captured output is the argument: both print an equally scrambled string, so running them tells you nothing about which one you can rely on. random is seeded from the OS at import, so Version A varies between runs too. It is unpredictable to a reader and reconstructible to an observer, which is exactly what makes it hard to catch.

Companion to
LLMs Can Write Code, but Cannot Read Your Mind
Files
password-random.py
password-secrets.py
Language
Python
password-random.py
"""
Version A of the password example: the insecure one.
Companion to "LLMs Can Write Code, but Cannot Read Your Mind"
https://hed.am/briefs/llms-can-write-code-but-cannot-read-your-mind/
    python3 password-random.py
`random` is a Mersenne Twister, built for simulation and modelling. It is
seeded from the OS at import, so the output does differ between runs, which
is what makes this one hard to catch: it looks random, and for a simulation
it is random enough. It does not, however, withstand an observer. The
generator's state is 624 32-bit words, and about that many consecutive
outputs are enough to reconstruct it and predict every value that follows.
Compare with password-secrets.py, which differs by one import.
"""
# Version A - looks fine, absolutely wrong for security
import random, string
ALPHABET = string.ascii_letters + string.digits
def password(n: int = 16) -> str:
    return ''.join(random.choice(ALPHABET) for _ in range(n))
print(password())
Version A — insecure
$ python3 --version
Python 3.14.6
$ python3 password-random.py
eLwHpk9WgNnHGTlH

Two hunks separate the two files; they are identical everywhere the diff does not mark. Their documentation blocks differ as well, compared above.

Version A — insecure Version B — correct
Documentation block: 9 lines removed, 12 added
Unified diff of the documentation blocks of password-random.py and password-secrets.py
"""
Version A of the password example: the insecure one.
Version B of the password example: the correct one.
Companion to "LLMs Can Write Code, but Cannot Read Your Mind"
https://hed.am/briefs/llms-can-write-code-but-cannot-read-your-mind/
    python3 password-random.py
    python3 password-secrets.py
`random` is a Mersenne Twister, built for simulation and modelling. It is
seeded from the OS at import, so the output does differ between runs, which
is what makes this one hard to catch: it looks random, and for a simulation
it is random enough. It does not, however, withstand an observer. The
generator's state is 624 32-bit words, and about that many consecutive
outputs are enough to reconstruct it and predict every value that follows.
Compare with password-secrets.py, which differs by one import.
The only difference from password-random.py is the import. `secrets` draws on
the operating system's cryptographic randomness (getrandom, /dev/urandom, or
the platform equivalent), which is designed so that seeing any amount of
previous output gives you no practical way to work out the next value.
`secrets.choice` also avoids the modulo bias that a naive `% len(alphabet)`
introduces when the alphabet does not divide the generator's range evenly.
Neither property is visible from the outside: both files print sixteen
scrambled characters, and no test anyone would think to write tells them
apart.
"""

Difference: 3 lines removed, 3 lines added.

Unified diff of password-random.py and password-secrets.py
# Version A - looks fine, absolutely wrong for security
import random, string
# Version B - looks the same, actually correct
import secrets, string
ALPHABET = string.ascii_letters + string.digits
def password(n: int = 16) -> str:
    return ''.join(random.choice(ALPHABET) for _ in range(n))
    return ''.join(secrets.choice(ALPHABET) for _ in range(n))
print(password())
Version B — correct
$ python3 --version
Python 3.14.6
$ python3 password-secrets.py
iOdOmqikpa00K57b

This file accompanies LLMs Can Write Code, but Cannot Read Your Mind, which is where the claim it checks is made.