Cryptography you don't write yourself
In short: You do not write cryptography yourself: in Python the secrets module gives random numbers, hmac.compare_digest compares in constant time, and AES comes from the cryptography package.
Problem
The code somebody already knows
The shop sends a 6-digit code for "forgot password". The same generator also makes the discount codes in the newsletter. The code looks random: 807592, 213904, 566120.
One night somebody changes a buyer’s password and shops with their card. He did not guess a million codes: he tried one. He knew the code before the buyer opened the email.
The generator is random.Random(started), and started is the second the shop started.
The idea
Random is a formula, not randomness
Random is a pseudorandom number generator: a formula that, from one number (the seed), computes a sequence that looks random. The same seed gives the same sequence. For games, simulations and tests that is useful. For secrets it is a disaster: whoever knows the seed, or can guess it, knows every next number.
The shop’s seed is the second it started. The attacker knows the shop was restarted in the night: 3600 possibilities. He has his own discount code from the newsletter. He tries every seed until the first code matches his. Then the next code of his copy is the buyer’s code.
For everything that has to be secret and unpredictable (tokens, codes, keys, salts, nonces) you use a cryptographic generator. It takes randomness from the operating system (hardware events, noise) and is built so that its next output cannot be computed from what came before.
See it
The attacker tries every second
def reset_token(): return secrets.token_urlsafe(32)
def code(): return f"{secrets.randbelow(1_000_000):06d}"def code(rng): return f"{rng.randrange(1_000_000):06d}"
def predict_next(seen, start, window): for i in range(window): guess = random.Random(start + i) if code(guess) == seen: print(f"Seed: try {i + 1}") return code(guess) return Nonedef reset_token(): return secrets.token_urlsafe(32)
def code(): return f"{secrets.randbelow(1_000_000):06d}"Step by step, in words
- The shop makes its codes with Random, and the seed is the second it started: 03:40:17. The first code goes into the newsletter, to the attacker as well.
- The next number from the same generator is the password reset code for a buyer. It goes by email.
- The attacker knows the shop restarted in the night. He tries every second from 03:00: one generator per second.
- At try 2418 the first code is the same as his discount code. That is the seed: 03:40:17.
- The next number of his copy is the buyer’s reset code. He changes the password and takes the account.
- The fix: the bytes come from the operating system’s cryptographic generator. There is no seed to guess.
- 3600 tries, or 3 million: no match. The token is 32 random bytes, and even a 6-digit code is no longer predictable.
def code(rng): return f"{rng.randrange(1_000_000):06d}"
def predict_next(seen, start, window): for i in range(window): guess = random.Random(start + i) if code(guess) == seen: print(f"Seed: try {i + 1}") return code(guess) return Nonenight = 1_793_070_000 # 27.10.2026, 03:00 UTCstarted = night + 2_417 # the shop restarted at 03:40:17rng = random.Random(started)discount = code(rng)reset = code(rng)print(f"Discount code from the newsletter (the attacker has it): {discount}")predicted = predict_next(discount, night, 3_600)print(f"Predicted password reset code: {predicted}")print(f"The real code sent to the buyer: {reset}")Discount code from the newsletter (the attacker has it): 573765Seed: try 2418Predicted password reset code: 271558The real code sent to the buyer: 271558The codes are different in the four languages, because the generators are different. The try is the same: 2418, because the seed is 03:40:17.
The idea
The cryptographic generator in every language
def reset_token(): return secrets.token_urlsafe(32)
def code(): return f"{secrets.randbelow(1_000_000):06d}"first = reset_token()second = reset_token()print(f"Token length: {len(first)}")print(f"Only A–Z, a–z, 0–9, - and _: {re.fullmatch(r'[A-Za-z0-9_-]+', first) is not None}")print(f"Two tokens in a row differ: {first != second}")print(f"The code is 6 digits: {re.fullmatch(r'[0-9]{6}', code()) is not None}")Token length: 43Only A–Z, a–z, 0–9, - and _: TrueTwo tokens in a row differ: TrueThe code is 6 digits: TrueThe token is 32 bytes (256 bits), written in URL-safe Base64: 43 characters that can go into a link. Guessing it is as hard as guessing an AES-256 key.
Thesecrets module is made exactly for this: token_urlsafe, token_hex, token_bytes, randbelow, choice. The random module is for simulations; its documentation says so in a red box.Try it
Predict the output
Two generators with the same seed. What will it print?
a = random.Random(42)b = random.Random(42)print(a.randrange(100) == b.randrange(100))print(a.randrange(100) == b.randrange(100))The same seed gives the same sequence, number by number. So the second numbers are equal too. This is exactly what the attacker relies on: his copy runs in step with the shop’s generator.
Try it
Fill in
A password reset token from the cryptographic generator:
token = secrets.(32)secrets.token_urlsafe(32) gives 32 random bytes written in URL-safe Base64.Try it
Find the bug
The token is long and looks safe. Which line makes it predictable?
Pick the line with the bug.
random.choice comes from the random module: a Mersenne Twister, whose state can be recovered from enough outputs. The right thing is secrets.choice, or straight away secrets.token_urlsafe(32).The idea
A comparison that gives nothing away
When the shop checks the code from the email, it compares it with the stored one. An ordinary string comparison stops at the first difference. The more leading characters are right, the longer it takes. Over the network the difference is nanoseconds, but with many tries and statistics it can be measured. That way the attacker guesses character by character: 10 tries for the first, 10 for the second…
The program below does not measure time: it counts how many characters the comparison looks at, which is what makes the time differ.
# What == does inside: it stops at the first differencedef compared_chars(secret, guess): i = 0 while i < len(secret) and secret[i] == guess[i]: i += 1 return min(i + 1, len(secret))secret = "481937"for guess in ["000000", "400000", "480000", "481000"]: print(f"{guess}: characters compared {compared_chars(secret, guess)}")for guess in ["481000", "481937"]: print(f"{guess}: compare_digest = {hmac.compare_digest(secret, guess)}")000000: characters compared 1400000: characters compared 2480000: characters compared 3481000: characters compared 4481000: compare_digest = False481937: compare_digest = TrueA constant-time comparison looks at every byte, whatever it finds, and the time does not depend on how much was guessed. Every language has it ready: hmac.compare_digest. Use it for everything secret that comes from outside: codes, tokens, JWT signatures, HMACs.
The idea
Encryption: a ready-made algorithm from a ready-made library
The shop keeps the IBAN of buyers who return goods. In the database it must be encrypted, and on reading it must be certain that nobody changed it. Both are done by authenticated encryption. The standard is AES-GCM: it encrypts and adds a 16-byte tag. If a single bit changes, the tag does not match and decryption stops with an error.
The Python standard library has no AES. It hashashlib, hmac and secrets, but no encryption. People use the cryptography package. Here is how it looks with it; it is not run here, because the site’s code check uses only the standard library:# pip install cryptography: it is not in the standard libraryfrom cryptography.hazmat.primitives.ciphers.aead import AESGCM
def encrypt(key, nonce, text): return AESGCM(key).encrypt(nonce, text.encode(), None)
def decrypt(key, nonce, box): return AESGCM(key).decrypt(nonce, box, None).decode() # InvalidTag if anything was changedWith the standard library alone you can guard against tampering with HMAC, but you cannot hide the text:
def seal(key, text): data = text.encode() tag = hmac.new(key, data, hashlib.sha256).digest() return data + tag
def unseal(key, box): data, tag = box[:-32], box[-32:] expected = hmac.new(key, data, hashlib.sha256).digest() if not hmac.compare_digest(tag, expected): raise ValueError("tag") return data.decode()
key = secrets.token_bytes(32) # in the real shop: from the secret storebox = seal(key, "BG80 BNBG 9661 1020 3456 78")print(f"Sealed: {len(box)} bytes")print(f"Opened: {unseal(key, box)}")tampered = bytearray(box)tampered[5] ^= 1try: unseal(key, bytes(tampered))except ValueError: print("One byte changed: the tag check failed, refusing")Sealed: 59 bytesOpened: BG80 BNBG 9661 1020 3456 78One byte changed: the tag check failed, refusingThe IBAN here is in plain view: 27 bytes of text and a 32-byte tag. This is integrity without secrecy. For secrecy you need AES-GCM from cryptography. In the real shop the key comes from the secret store, as in the lesson on secrets.
See it
A scheme of your own, broken in a few lines
A colleague decides AES is "too complicated" and writes his own encryption: every byte XOR a byte of a secret word. It looks like noise. But XOR undoes itself, and every invoice starts with the same words:
def xor(data, key): return bytes(b ^ key[i % len(key)] for i, b in enumerate(data))key = "SHOP2026".encode() # the shop’s "secret" keyinvoice = xor("INVOICE 2026-0142, total 249.90 €".encode(), key)iban = xor("BG80 BNBG 9661 1020 3456 78".encode(), key)# the attacker: every invoice starts like thisknown = "INVOICE ".encode()found = xor(invoice[:8], known[:8])print(f"The key is: {found.decode()}")print(f"Another message: {xor(iban, found).decode()}")The key is: SHOP2026Another message: BG80 BNBG 9661 1020 3456 78The secret word comes out of the first 8 bytes of one invoice, and with it everything ever encrypted can be read. No maths and no billions of tries are needed, only a known start of the text.
That is the rule of this lesson: do not invent your own cryptography. Not your own algorithm, not your own scheme around a ready-made algorithm, not your own generator. The people who make cryptography publish their schemes and wait years for others to break them. You use their work through a library.
More precisely, if you are curious
Which for what.
- Passwords: a slow hash with a salt (bcrypt, argon2id), the lesson on passwords. Never encryption: a password must not be readable back.
- Signing a message with a shared key: HMAC-SHA256, as in JWT.
- Encrypting data: AES-GCM (or ChaCha20-Poly1305) with a key from a secret store.
- Tokens and codes: the cryptographic generator.
- A hash for the integrity of a file: SHA-256 or SHA-512, as in the lesson on dependencies. MD5 and SHA-1 are broken for this.
Higher-level libraries. AES-GCM still requires you never to repeat a nonce and to guard the key. Libraries like libsodium (NaCl), Google Tink and Fernet in Python’s cryptography hide these decisions too: you get encrypt(key, text) and decrypt(key, box), with no modes and no nonce.
Constant-time comparison inside. It compares every byte and collects the differences with XOR and OR: diff |= a[i] ^ b[i], and at the end checks diff == 0. There is no early exit. You do not write it yourself: the compiler may "optimise" it; the ready-made functions are built so that this cannot happen.
The 6-digit code. Even from a cryptographic generator, 6 digits are a million possibilities. So the code expires after minutes and locks after a few wrong tries. A token in a link is 32 bytes, and there these limits are not needed against guessing, only against leaks (in a log, for example).
UUID. UUID.randomUUID() in Java, uuid.uuid4() in Python and crypto.randomUUID() in Node come from a cryptographic generator. Guid.NewGuid() in .NET does too in practice, but the documentation does not promise it, so for secrets use RandomNumberGenerator. A UUID itself is 122 random bits: fine for an id, but a 32-byte token is clearer.
Exercise
Discount codes
Solve it in your own editor.
The shop wants 10-character discount codes that are easy to read: only capital Latin letters and digits, without 0, O, 1 and I, which get confused. The alphabet is ABCDEFGHJKLMNPQRSTUVWXYZ23456789 (32 characters, 50 bits per code). Write discount_code with the cryptographic generator, make 1000 codes and check that all are from the alphabet and all are distinct:
Codes: 1000Each is 10 characters from the alphabet: TrueDistinct: 1000secrets.choice(alphabet) picks one character.More tasks
- Expiry. Store when each code was made and refuse codes older than 15 minutes.
- Tries. Lock the code after 5 wrong tries and count the tries.
- No modulo. Why is
ALPHABET[randomByte % 32]fine, butALPHABET[randomByte % 30]is not? (Hint: 256 divides by 32, but not by 30.)
Test
Check yourself
Related lessons
Section titled "Related lessons"- Builds on: Lambdas · Passwords · JWT and its traps · Secrets in code and in git
- Tasks on this topic: Security for developers