All of cryptography rests on a single foundation: values that an attacker cannot predict. Encryption keys, session tokens, the salts that will accompany passwords (module 3), and the IVs that will accompany encryption (module 2) — all of them are, at bottom, "random numbers." If randomness fails, everything built on top of it fails too, no matter how perfect the algorithm is: it's the cryptographic equivalent of building a bunker with the door left open. In this lesson you'll learn what entropy is, the critical difference between ordinary pseudorandom generators (PRNG) and cryptographic ones (CSPRNG), where the operating system gets its randomness from, and how to generate secure values in Python with secrets and os.urandom — including the password-recovery token for MediNube's users.
Contents
- Why randomness is the foundation of everything
- Entropy: measuring unpredictability
- PRNG vs. CSPRNG
- Where the operating system gets its entropy
- In Python:
random(NO) vs.secretsandos.urandom(YES) - MediNube case: password-recovery token
- Key space and bits of security: why 2^128 is out of reach
Why randomness is the foundation of everything
Remember the previous lesson: the security of encryption doesn't lie in the algorithm's secrecy but in the key. That only works if the key is unpredictable. Let's review which unpredictable values a system like MediNube needs:
| Value | What it's for | What happens if it's predictable |
|---|---|---|
| Encryption keys | Encrypting records and documents | The attacker regenerates the key and decrypts everything |
| Session tokens | Identifying the logged-in doctor | Hijacking other people's sessions |
| Password-recovery tokens | The "I forgot my password" link | Taking over any account |
| Salts (module 3) | Accompanying each password's hash | Precomputed attacks against the password table |
| IVs / nonces (module 2) | Making sure encrypting the same thing twice goes unnoticed | Pattern leaks in the ciphertext |
| Unguessable identifiers | URLs for shared documents | Enumeration of other users' resources |
And this isn't a theoretical risk. Two famous historical examples:
- Netscape (1995): the SSL session-key generator was seeded from the time of day and the process ID. An attacker who knew roughly when the connection was created could narrow the possibilities down to a handful and break the session in seconds.
- Debian OpenSSL (2006-2008): a patch accidentally removed nearly all of the generator's entropy source; for almost two years, SSH and SSL keys generated on Debian came from a space of only 32,767 possibilities. Every single one could be regenerated and broken.
In both cases the encryption algorithms were sound. The foundation failed.
Entropy: measuring unpredictability
Entropy (in the information-theory sense) measures how much uncertainty a value holds for someone trying to guess it, and it's expressed in bits. The intuition:
- A value with n bits of entropy is as hard to guess as a sequence of n flips of a fair coin: there are 2^n equally likely possibilities.
- 1 bit = 2 possibilities. 10 bits = 1,024. 20 bits ≈ one million. 128 bits ≈ 3.4 × 10^38.
The crucial part is that entropy measures the possibilities from the attacker's point of view, not the value's appearance:
7f3k9x2mlooks random, but if it was generated by choosing among 1,000 possible values (for example, from a timestamp in seconds within the last hour), it has ~10 bits of entropy: it breaks instantly.- Length is deceptive: a version-1 UUID is long, but it's built from the time and the MAC address — a lot of length, very little entropy. (Version-4 UUIDs are random, with 122 bits.)
- A 20-character password made of two dictionary words and a year (
clinicasol2026doctor) has much less entropy than 10 uniformly random characters.
Mental rule: entropy = log2(number of equally likely possible values). When in doubt about a generator, don't ask "what does the output look like?" but rather "how many distinct values could it have produced, and could the attacker narrow that down?"
PRNG vs. CSPRNG
Computers are deterministic machines: they don't produce randomness out of thin air. What they do is expand a seed through an algorithm. And here two worlds diverge:
PRNG (Pseudo-Random Number Generator)
Designed for statistics and simulation: the output should "look" uniform, be extremely fast, and (often) be reproducible on demand. Python's random module uses the Mersenne Twister, excellent for simulating dice rolls or shuffling test data... and catastrophic for security:
- It's deterministic and recoverable: by observing 624 outputs of 32 bits, an attacker can reconstruct the entire internal state and predict all future outputs (public tools exist that do exactly this).
- It can be seeded explicitly (
random.seed(42)), and by default may be seeded from poor sources. Reproducibility, a virtue in simulation, is poison in security.
CSPRNG (Cryptographically Secure PRNG)
Designed for adversaries: even if the attacker knows the algorithm (Kerckhoffs, lesson 01-04) and observes any number of outputs, they cannot predict the next one or reconstruct previous ones. It's continuously reseeded with real system entropy, and its internal state is protected.
| Property | PRNG (random) |
CSPRNG (secrets, os.urandom) |
|---|---|---|
| Design goal | Good statistical distribution, speed | Unpredictability against an adversary |
| Predictable by observing outputs? | Yes (Mersenne Twister: with 624 outputs) | No (computationally infeasible) |
| Seedable/reproducible? | Yes (seed()) |
Not from the application |
| Seed source | Can be poor | Operating system entropy |
| Correct uses | Simulations, games, tests, sample data | Keys, tokens, salts, IVs, anything security-related |
| In Python | random.* |
secrets.*, os.urandom() |
The rule is binary, with no exceptions: if an attacker gains anything by guessing the value, use a CSPRNG. Always.
Where the operating system gets its entropy
If the computer is deterministic, where does the initial unpredictability come from? The operating system collects physical noise from the real world: micro-variations in timing between hardware interrupts, mouse movements and keystrokes, disk access times, thermal noise, and, on modern CPUs, dedicated hardware instructions (like RDRAND on x86). All of these sources are mixed into an entropy "pool" that feeds a CSPRNG in the kernel.
On Linux, that kernel CSPRNG is exposed in three ways:
/dev/urandom: the classic device; returns bytes from the CSPRNG without blocking. It's the correct interface for practically everything./dev/random: historically blocked when an entropy counter "ran out" — a precaution now considered unnecessary (once properly seeded, a CSPRNG doesn't "run out"). On modern kernels (≥ 5.6) it behaves almost identically tourandom.getrandom(): the modern system call (since Linux 3.17). Likeurandom, but with one important extra guarantee: it blocks only if the pool hasn't been initialized yet (for example, in the first instants after booting a freshly cloned virtual machine — the one scenario whereurandomcould be dangerous). This is what Python uses under the hood when available.
graph TD
F1[Interrupt timings] --> P[Kernel entropy pool]
F2[Mouse / keyboard / disk] --> P
F3[Hardware RNG<br/>RDRAND, TPM] --> P
P --> CS[Kernel CSPRNG]
CS --> U["/dev/urandom"]
CS --> G["getrandom()"]
G --> PY["Python: os.urandom / secrets"]
U --> PY
For you as a developer the conclusion is reassuring: don't manage entropy by hand. Don't mix in your own sources, don't "add randomness" with timestamps. Ask the operating system for bytes through the correct APIs, and that's it. Windows and macOS have equivalent mechanisms (CryptGenRandom/BCryptGenRandom, getentropy), and Python abstracts over all of them.
In Python: random (NO) vs. secrets and os.urandom (YES)
The wrong module for security: random
import random random.seed(1234) # reproducible: useful in tests, fatal in security! print(random.randint(0, 999999)) # 997185 — always the same with this seed print(random.random()) # 0.9664535356921388 — same story
Python's own documentation warns about this: "the pseudo-random generators of this module should not be used for security purposes." Anything that comes out of random should be considered known to the attacker.
The right modules: os.urandom and secrets
os.urandom(n) returns n bytes from the system's CSPRNG (via getrandom() on modern Linux). secrets (since Python 3.6) is a convenience layer over the same source, with functions designed exactly for security use cases:
import os import secrets # Raw bytes: how you generate material for a future key key_material = os.urandom(32) # 32 bytes = 256 bits print(key_material.hex()) # e.g. '9f86d081884c7d659a2f...' # Equivalent with secrets: key_material = secrets.token_bytes(32) # Bytes represented in hexadecimal (a str ready to store/display) print(secrets.token_hex(16)) # 16 bytes -> 32 hex characters # Bytes in URL-safe Base64 (a str ready to drop into a URL) print(secrets.token_urlsafe(32)) # e.g. 'Drmhze6EPcv0fN_81Bj-nA...' # Secure choices over collections alphabet = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789" # no ambiguous characters (0/O, 1/I) invite_code = "".join(secrets.choice(alphabet) for _ in range(10)) # Secure integers secure_die = secrets.randbelow(6) + 1 # uniform integer in [1, 6]
Point by point:
os.urandom(32)andsecrets.token_bytes(32)are equivalent; use whichever makes the code more readable. 32 bytes = 256 bits of entropy, the size we'll use for AES-256 keys in module 2.token_hex(n)andtoken_urlsafe(n)take the number of bytes of entropy and return an already-encodedstr(lesson 01-02 in action: the token is bytes, the hex/Base64 is its packaging!).token_urlsafe(32)produces ~43 characters.secrets.choiceis the secure version ofrandom.choice;secrets.randbelow(n)gives unbiased uniform integers (avoiding the classic mistake ofos.urandom(1)[0] % 6, which introduces bias because 256 isn't a multiple of 6).- How much should you request? For tokens, 32 bytes (256 bits) is a generous, standard choice; 16 bytes (128 bits) is today's serious minimum.
MediNube case: password-recovery token
MediNube's "I forgot my password" flow emails a link containing a token. If that token is guessable, an attacker takes over any doctor's or patient's account without ever touching the password. Compare the two implementations:
# ─── VULNERABLE VERSION (found in MediNube's legacy code) ───
import random, time
def recovery_token_BAD(username: str) -> str:
random.seed(time.time()) # seed = current time
return f"{username}-{random.randint(100000, 999999)}"
# e.g. 'ana.perez-483920'Problems, from most to least severe:
- Predictable seed: the attacker knows (from the email header, or simply by trying) roughly which second the token was requested; trying a few dozen nearby seeds regenerates the exact token.
- Tiny space: even if the seed were perfect, there are only 900,000 values — ~20 bits of entropy. A script can try all of them against the endpoint in minutes.
- Non-cryptographic PRNG:
randomis predictable by design, as we already know. - It includes the username, giving away structure for free.
# ─── CORRECT VERSION ───
import secrets
def generate_recovery_token() -> str:
"""One-time token to reset a password. 256 bits of entropy."""
return secrets.token_urlsafe(32)
token = generate_recovery_token()
link = f"https://portal.medinube.example/recover?token={token}"
# https://portal.medinube.example/recover?token=hklR3ZbG8vN2wq...secrets.token_urlsafe(32): 32 bytes from the system CSPRNG → 2^256 possibilities. Impossible to guess, impossible to regenerate, with no structure.- The URL-safe format lets you drop it straight into the email link (lesson 01-02).
- Cryptography solves unpredictability; the rest is application logic that matters too: a short expiry (e.g. 30 minutes), single use, invalidating it once used. And as we'll see in module 3, the database should store a hash of the token, not the token itself — just like with passwords.
Warning: in a real healthcare system, the entire account-recovery flow (email, expiry, auditing) must be reviewed with security and compliance professionals (GDPR); here we're only showing the cryptographic piece.
Key space and bits of security: why 2^128 is out of reach
We talk about "n bits of security" when the best known attack requires on the order of 2^n operations. For pure brute force, n is the size of the key space. The progression is brutal, because every bit doubles the work:
| Bits | Possible values | Is brute force viable? |
|---|---|---|
| 20 | ~10^6 | Instant on a laptop |
| 40 | ~10^12 | Minutes or hours on a laptop |
| 56 (DES, 1977) | ~7 × 10^16 | Broken in days back in 1998 (a $250,000 machine); trivial today |
| 80 | ~10^24 | At the edge of nation-state actors; no longer considered secure |
| 128 | ~3.4 × 10^38 | No, not remotely, for anyone |
| 256 | ~10^77 | Extra margin (relevant against quantum computing, module 6) |
Why is 2^128 a physical barrier and not just a practical one? Let's run the numbers conservatively, generous to the attacker:
attempts_per_second = 10**12 # one trillion keys/s per machine (very generous)
machines = 10**9 # one billion machines in parallel
seconds_per_year = 3.15 * 10**7
years = 2**128 / (attempts_per_second * machines * seconds_per_year)
print(f"{years:.1e} years") # ~1.1e+10 yearsWith a billion machines each trying a trillion keys per second, exhausting 2^128 keys would take on the order of 10.8 billion years — comparable to the age of the universe (~13.8 billion). And the energy needed to simply count up to 2^128 at the theoretical maximum physical efficiency exceeds all of humanity's energy production by absurd margins. That's why real attacks are never brute force against a good key: they target badly generated keys (this lesson), algorithms that leak patterns (module 2), low-entropy human passwords (module 3), or flawed implementations (lesson 01-04).
Watch the fine print: 128 bits of security require 128 bits of actual entropy. A 256-bit key derived from a password with 30 bits of entropy is a 30-bit key in disguise — the bridge between passwords and keys (KDFs) is built in module 2 (lesson 02-04).
Two quick mentions so you meet them later with the concept already clear: salts (public random values that make each password hash unique) are covered in module 3, and IVs/nonces (random or unique values that make each encryption unique) in module 2. Both are direct consumers of what you learned today: they're generated with the CSPRNG, though — unlike keys — they aren't secret.
Common Mistakes and Tips
- Using
randomfor anything security-related. Mistake number one in Python. Tokens, verification codes, temporary passwords, "shuffling" CAPTCHA options — all of that calls forsecrets. Recommended search across your codebase:grep -rn "import random"and review every use. - Seeding with the time (
seed(time.time())) believing it adds security. It's exactly the opposite: it makes the seed guessable. CSPRNGs are never seeded from the application. - Judging entropy by appearance. A long, "ugly-looking" value can have 10 bits of entropy if the process that generated it had few possibilities. Always ask about the process, not the look.
- Reducing entropy after generating it. Generating 32 perfect bytes and then keeping
token[:6]"so the code fits in the SMS" leaves you with 6 characters. Sometimes that's a necessary trade-off (6-digit OTP codes), but then it must be compensated with strict attempt limits and expiry. - Implementing your own generator ("I mix the timestamp with the PID and hash it"). That's Netscape's 1995 mistake. The operating system already does it right; use it.
- Tip: standardize on a single helper across your team (e.g.
secrets.token_urlsafe(32)) for every token; code reviews become trivial — any other source of randomness is a red flag.
Exercises
Exercise 1. Calculate (or reason about) the bits of entropy of each value and rank them from weakest to strongest: (a) a 4-digit PIN; (b) a 6-letter code chosen uniformly from a 32-symbol alphabet with secrets.choice; (c) secrets.token_hex(16); (d) a "token" made from the Unix timestamp in seconds at the moment of the request (the attacker knows the time within a ±1-hour margin). Hint: entropy = log2(possibilities); for (b), each symbol from a 32-symbol alphabet contributes log2(32) = 5 bits.
Exercise 2. MediNube's legacy code contains this function for generating temporary passwords for clinic staff. Identify all the problems and rewrite it correctly with secrets:
import random, string
def temporary_password():
random.seed() # "so that it's random"
return "".join(random.choice(string.ascii_lowercase) for _ in range(8))Exercise 3. MediNube wants unguessable identifiers for shared-document URLs (/doc/<id>), about 22 URL-safe characters long. (a) Write the function. (b) Calculate how many bits of entropy your proposal has. (c) If MediNube generates a million identifiers, is there any appreciable risk of collision (two documents with the same id)? Reason using orders of magnitude.
Solutions
Solution 1. (a) 4-digit PIN: 10^4 = 10,000 possibilities ≈ 13.3 bits — trivial. (d) Timestamp with a ±1-hour margin: 7,200 possibilities ≈ 12.8 bits — trivial (and with structure!). (b) 6 symbols × 5 bits = 30 bits ≈ 10^9 possibilities — weak for a token attackable offline, tolerable only with a strict limit on online attempts. (c) token_hex(16) = 16 bytes = 128 bits — out of reach. Order: d < a < b < c. Note: (d) is the worst despite "looking" like a big number — appearance deceives, the process is what counts.
Solution 2. Problems: (1) it uses random, a predictable PRNG, for a security-sensitive value; (2) random.seed() with no argument fixes nothing — the output is still predictable if the state is recovered, and the comment reveals a false belief; (3) lowercase letters only: 26^8 ≈ 2 × 10^11 ≈ 37.6 bits — an offline attack exhausts it quickly; (4) 8 characters is short for the chosen alphabet. Correct version:
import secrets, string
def temporary_password(length: int = 16) -> str:
alphabet = string.ascii_letters + string.digits # 62 symbols
return "".join(secrets.choice(alphabet) for _ in range(length))16 characters × log2(62) ≈ 95 bits: plenty for a temporary password, which should also expire soon and force a change on first login. (How to store it securely is the subject of lesson 03-03.)
Solution 3. (a) We already know the exact tool for "random + URL-safe":
import secrets
def document_id() -> str:
return secrets.token_urlsafe(16) # 16 bytes -> ~22 URL-safe characters(b) The entropy comes from the bytes, not the characters: 16 bytes = 128 bits. (c) Collision risk: with n identifiers in a space of 2^128, the probability of any collision is approximately n²/2^129 (the birthday paradox, which will reappear in module 3 with hashes). With n = 10^6: (10^6)² / 2^129 ≈ 10^12 / 6.8 × 10^38 ≈ 10^-27 — indistinguishable from zero for any practical purpose. They can be generated without checking uniqueness, although a UNIQUE constraint in the database as a safety belt never hurts.
Conclusion
You now know the foundation of the whole edifice: cryptographic security is measured in bits of entropy, which count the possibilities from the attacker's perspective; PRNGs like random are predictable by design and are banned from any security use; the operating system's CSPRNGs (fed with physical noise and exposed via /dev/urandom and getrandom()) are the only legitimate source, and in Python they're consumed through secrets and os.urandom. You've seen that 128 bits put brute force beyond the reach of physics, you've generated material for future keys with os.urandom(32), and you've replaced MediNube's vulnerable recovery token with secrets.token_urlsafe(32).
That leaves one question hanging: if the algorithm can be public and all the security lies in the key... why exactly is that an advantage and not recklessness? The answer has a name — Kerckhoffs's principle — and from it follow the golden rules that will govern the rest of the course. That's the subject of this module's final lesson.
Applied Cryptography Course
Module 1: Cryptography Fundamentals
- What Cryptography Is and What It's For
- Encoding, Obfuscation, and Encryption
- Randomness and Entropy
- Kerckhoffs's Principle and the Golden Rules
Module 2: Symmetric Cryptography
- Symmetric Encryption: AES and ChaCha20
- Modes of Operation
- Authenticated Encryption (AEAD)
- Key Derivation (KDF)
Module 3: Hashes, MACs, and Passwords
Module 4: Asymmetric Cryptography
- Public-Key Fundamentals and RSA
- Elliptic Curve Cryptography
- Digital Signatures
- Key Exchange: Diffie-Hellman
- Hybrid Encryption
