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

  1. Why randomness is the foundation of everything
  2. Entropy: measuring unpredictability
  3. PRNG vs. CSPRNG
  4. Where the operating system gets its entropy
  5. In Python: random (NO) vs. secrets and os.urandom (YES)
  6. MediNube case: password-recovery token
  7. 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:

  • 7f3k9x2m looks 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 to urandom.
  • getrandom(): the modern system call (since Linux 3.17). Like urandom, 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 where urandom could 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) and secrets.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) and token_urlsafe(n) take the number of bytes of entropy and return an already-encoded str (lesson 01-02 in action: the token is bytes, the hex/Base64 is its packaging!). token_urlsafe(32) produces ~43 characters.
  • secrets.choice is the secure version of random.choice; secrets.randbelow(n) gives unbiased uniform integers (avoiding the classic mistake of os.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:

  1. 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.
  2. 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.
  3. Non-cryptographic PRNG: random is predictable by design, as we already know.
  4. 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 years

With 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 random for anything security-related. Mistake number one in Python. Tokens, verification codes, temporary passwords, "shuffling" CAPTCHA options — all of that calls for secrets. 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.

© Copyright 2026. All rights reserved