Randomness in Cryptography: PRNGs, CSPRNGs, Entropy, and Real-World Failures
Every key, nonce, IV, and salt in cryptography traces back to the same question: where did the randomness come from? Get that answer wrong and it doesn’t matter how strong the cipher around it is. This post works through what “random” actually means, how to measure it, how operating systems and programming languages generate it, and three real incidents where getting it wrong cost real money and real keys.
What “Random” Actually Means
Here’s a scenario: a lottery machine draws six balls and the result is 1, 2, 3, 4, 5, 6. Everyone’s first reaction is that it’s rigged. But run the math: that exact sequence is precisely as likely as any other six-number combination the machine could produce. 1,2,3,4,5,6 and, say, 7,14,22,31,38,45 have identical probability. What differs is not the chance of occurring, it’s how strongly the pattern jumps out at a human brain wired to hunt for structure.
That’s the core idea worth internalizing before anything else in this post: randomness is a property of the process that generates a value, not a property you can read off the value itself by squinting at it. A sequence is random if the mechanism behind it gave every possible outcome an equal shot, full stop. Whether the resulting string looks messy or looks suspiciously tidy tells you nothing about how it was produced.
This matters immediately once you move from lottery balls to security. Picture a chat app assigning session IDs. One session gets aaaaaaaa. Another gets k3f9zQ2x. Both are 8-character strings drawn from the same alphabet. If the app’s ID generator is a proper CSPRNG, aaaaaaaa is exactly as likely to come out as k3f9zQ2x, neither one is more “random” than the other, and both are equally hard to guess in advance. The dangerous mistake is assuming a messy-looking ID proves the generator is doing its job, or that a suspiciously repetitive one proves it’s broken. An attacker never needs your output to look weird. They need it to be guessable, and guessability is a fact about the generating process, not about the string sitting in a database column.
flowchart LR
A["Generating process"] -->|"equal chance to every outcome"| B["Uniform / secure"]
A -->|"biased toward some outcomes"| C["Biased / exploitable"]
B --> D["Output looks messy: aaaaaaaa OR k3f9zQ2x\n(either is fine)"]
C --> E["Output looks messy too\n(still guessable underneath)"]
style D fill:#1f6f4a,color:#fff
style E fill:#8f2d2d,color:#fff
The diagram above is the whole point of this section in one picture: “looks random” and “is random” are two different axes, and only the left branch is one a defender can rely on.
Probability Distributions and Uniformity
To talk about randomness precisely, cryptography borrows a small piece of vocabulary from probability theory. A distribution is just a table: every possible outcome, paired with a weight between 0 and 1, and all the weights across the table add up to exactly 1. That’s the entire concept. When every row in that table carries the same weight, 1/N for N possible outcomes, the distribution is uniform. When some rows carry more weight than others, it’s biased.
Uniform is the target for key generation, and the reason is mechanical rather than aesthetic: if a key is drawn from an N-bit space but the distribution over that space is biased, the effective number of keys an attacker has to try before expecting a hit is smaller than 2^N, sometimes drastically smaller, even though the key still displays as N bits long. The nominal size of the space stops meaning anything once the draw isn’t flat across it.
Here’s a concrete way this goes wrong. Imagine a “random” 4-digit PIN generator that’s supposed to be uniform over 0000-9999, implemented as system_clock_ms % 10000, grabbing the last four digits of the millisecond clock. That looks reasonable and it isn’t. Clock reads cluster: interrupt timers, scheduler tick granularity, and system call overhead all nudge the low digits of a millisecond counter toward certain values far more often than a genuine uniform draw would. It’s a world apart from rolling a fair ten-sided die four times, where each digit really does get an equal shot independent of everything else happening on the machine.
The code below makes the shape of that failure visible without needing a real clock. It draws 100,000 digits with Python’s uniform randint(0, 9), then draws 100,000 digits from a deliberately biased generator, one that pulls from 0-12 and reduces mod 10 (13 doesn’t divide 10 evenly, so digits 0, 1, and 2 each get two source values instead of one):
import random
N = 100_000
def skewed_digit():
# naive "PIN digit" generator: draw from 0..12 then reduce mod 10.
# 13 doesn't divide 10 evenly, so digits 0,1,2 get an extra chance,
# mimicking how clock-tick-derived generators skew low digits.
return random.randint(0, 12) % 10
uniform_counts = [0]*10
skewed_counts = [0]*10
for _ in range(N):
uniform_counts[random.randint(0, 9)] += 1
skewed_counts[skewed_digit()] += 1
print("digit uniform skewed")
for d in range(10):
print(f" {d} {uniform_counts[d]:6d} {skewed_counts[d]:6d}")
Running it produces:
digit uniform skewed
0 10057 15278
1 9941 15370
2 10014 15277
3 9933 7648
4 9948 7798
5 9867 7831
6 10042 7817
7 10102 7591
8 10028 7722
9 10068 7668
The uniform column sits close to 10,000 across every digit, as expected. The skewed column tells a different story: digits 0, 1, and 2 land around 15,300 each, roughly double the ~7,700 the remaining seven digits get. Nothing here required a hostile input or a bug in the usual sense, just an innocent-looking reduction step, and it’s enough to cut the real guessing difficulty of that PIN generator by a meaningful factor.
Entropy: Quantifying Uncertainty
“Biased” and “uniform” describe shape, but cryptography needs a number, something you can compare across designs. That number is entropy, measured in bits, and it’s computed with:
summed over every possible outcome . A handful of reference points make this formula concrete. A fair coin, two outcomes at probability 0.5 each, scores exactly 1 bit. A value drawn uniformly from all possible n-bit strings scores exactly n bits, which is also the ceiling: nothing built over n-bit strings can score higher than n, no matter how the probabilities are arranged. Push the distribution away from flat and entropy drops below that ceiling. A coin that lands heads 90% of the time isn’t giving you anywhere near 1 bit of real uncertainty, because guessing “heads” every time wins nine times out of ten.
To make the formula land as more than algebra, take a loaded six-sided die: one face fixed at probability 0.5, the other five faces splitting the remaining 0.5 evenly, 0.1 each. Compare that to a fair die, where every face sits at 1/6. Both dice still show six faces. Only one of them is actually giving you close to bits of real uncertainty per roll:
import math
def entropy(probs):
return -sum(p * math.log2(p) for p in probs if p > 0)
fair_die = [1/6]*6
loaded_die = [0.5, 0.1, 0.1, 0.1, 0.1, 0.1]
print(f"fair die entropy: {entropy(fair_die):.4f} bits")
print(f"loaded die entropy: {entropy(loaded_die):.4f} bits")
fair die entropy: 2.5850 bits
loaded die entropy: 2.1610 bits
bits for the fair die, matching the ceiling for six equally-likely outcomes exactly. The loaded die comes in at 2.161 bits, noticeably lower, quantifying in one number what “carries less real randomness despite still offering six faces” actually means. That gap, 2.585 down to 2.161, is the entropy formula doing exactly what it’s for: turning “feels less random” into a comparable, checkable quantity.
RNGs vs PRNGs: Where True Randomness Comes From
Entropy needs a source, and a deterministic machine, on its own, cannot manufacture unpredictability out of pure math. Every step a CPU takes is a function of its previous state; give it the same starting conditions twice and it produces the same result twice, forever. So genuine unpredictability has to come from somewhere physical and chaotic: thermal noise in a resistor, the jitter in exactly when a hardware interrupt fires, variance in how long a disk seek takes, radioactive decay timing. A component that taps sources like these for a slow trickle of high-quality unpredictable bits is called an RNG (a true random number generator).
The catch is that RNGs are slow, and modern software needs randomness by the megabyte, instantly, for keys, nonces, padding, session tokens, all of it. That’s the job of a PRNG (pseudorandom number generator): take a comparatively small chunk of that hard-won entropy as a seed, then run it through a deterministic algorithm engineered to stretch it into an arbitrarily long stream that’s computationally indistinguishable from true randomness to anyone who doesn’t know the seed. The security of the whole stream rests entirely on two conditions holding: the seed stayed secret, and the seed had enough entropy going in. Get either one wrong and the “randomness” downstream is fake no matter how sophisticated the stretching algorithm is.
A useful mental picture: an RNG is a single dripping faucet, slow, and the exact instant each drop falls is genuinely unpredictable. A PRNG is a machine that takes one drop’s precise timing as a starting number and, from that single number, cranks out a firehose of numbers that all look equally unpredictable to an outside observer, for as long as nobody ever saw that original drop. The firehose is fast and abundant. But its entire unpredictability traces back to one hidden drop of water.
flowchart LR
subgraph Physical world
T["Thermal noise"]
J["Interrupt jitter"]
D["Disk seek variance"]
end
T & J & D --> RNG["RNG\n(slow trickle of\nhigh-entropy bits)"]
RNG -->|"seed"| PRNG["PRNG\n(deterministic stretch)"]
PRNG --> K["Keys"]
PRNG --> N["Nonces"]
PRNG --> P["Padding"]
Inside a PRNG: Entropy Pools and the init/refresh/next Cycle
Nearly every production-grade PRNG, regardless of which specific algorithm sits at its core, is built around the same shape: an internal buffer, usually called a pool or state, that’s continually stirred with fresh unpredictable input, exposed to the rest of the system through three operations. init sets the pool to a known starting point. refresh (often called reseeding) mixes new entropy into the pool as it becomes available. next runs a deterministic algorithm over the pool to hand back the requested number of pseudorandom bytes, while also updating the pool internally so that asking twice never produces the same output twice.
Two security guarantees are what separate a real PRNG design from “a loop that XORs some numbers together.” Forward secrecy means that if an attacker somehow gets a snapshot of the current pool state, they still cannot work backward and reconstruct bits the generator produced earlier. Backward secrecy means the mirror image: knowing the current state doesn’t let an attacker predict future output either, unless they also learn whatever fresh entropy gets mixed in next. Both guarantees depend on the pool’s mixing and output functions being one-way, not merely deterministic, deterministic alone just means repeatable, one-way means the sequence of pool snapshots resists being run in reverse or forward without the missing entropy.
Walk through a concrete (invented) example to see why this matters. Picture a small IoT thermostat booting up. Its PRNG pool starts zeroed. Within the first couple of seconds, two entropy top-ups arrive, one from jitter in an ADC sensor reading, another from the exact arrival timing of an incoming Wi-Fi packet, both mixed into the pool via refresh. The thermostat’s pairing app then calls next(16) to generate a 16-byte device pairing code shown to the user. Now suppose an attacker, hours later, physically dumps that chip’s RAM and recovers the pool’s current state. Forward secrecy is the property that makes this dump useless for recovering that earlier pairing code: the mixing step that consumed the ADC and Wi-Fi entropy was one-way, so having today’s pool contents doesn’t hand you a path back to the pool contents that existed at the moment the pairing code was generated. Two real production designs built on exactly this pool-plus-cipher shape are Fortuna and Yarrow; naming them is as far as this post goes, their internals are their own topic.
Here’s the shape as pseudocode, not a working implementation, just the skeleton described above made concrete:
# pseudocode only, not runnable, illustrates the shape described above
def init():
pool = zero_bytes(POOL_SIZE)
def refresh(seed_bytes):
pool = one_way_mix(pool, seed_bytes) # e.g. hash-based mixing
def next(n_bytes):
output, pool = one_way_generate(pool, n_bytes) # produces output,
return output # advances pool state
stateDiagram-v2
[*] --> Zeroed: init()
Zeroed --> Seeded: refresh(entropy)
Seeded --> Seeded: refresh(more entropy)
Seeded --> Producing: next(n)
Producing --> Seeded: pool advances\n(one-way step)
note right of Producing
Forward secrecy: old pool state
not recoverable from new state
Backward secrecy: future output
not predictable without new entropy
end note
Cryptographic vs Non-Cryptographic PRNGs
Not every PRNG needs to survive an adversary, and that split matters enormously. A PRNG built for simulations, procedurally generated game content, or shuffling a music playlist only has to pass statistical tests: are digit frequencies roughly even, is the cycle length long enough that it doesn’t visibly repeat. Nobody checks whether watching past outputs lets you compute future ones, because for those use cases nobody’s attacking the generator.
A CSPRNG (cryptographically secure PRNG) has to clear a much higher bar. It must survive an attacker who has already seen every prior output and knows the exact algorithm in use. Given any string of observed bits, the next bit produced must remain as hard to guess as a fresh coin flip. That requirement forces both the state-update function and the output function to be one-way: knowing outputs must not leak a practical path to the internal state, and knowing the state must not make future outputs solvable.
Non-cryptographic generators typically fail this bar through linearity. If a generator’s next-state and output functions are built purely from XOR and bit-shift operations on the internal state, and nothing else, the entire relationship between initial state and every future output byte is a system of linear equations over the initial unknowns. Collect enough output samples and that system becomes solvable with ordinary linear algebra, no brute-force search, no cryptanalysis in the usual sense, just Gaussian elimination. Speed is exactly why non-cryptographic generators use linear operations in the first place: XOR and shift are essentially free on real hardware, which is also exactly why the resulting stream is transparent to anyone holding enough samples.
The best-documented real example is the Mersenne Twister, historically the default general-purpose generator behind PHP’s rand(), Python’s random module, Ruby’s Kernel#rand, and R. It carries 624 words of internal state, and it’s public, well-established fact that 624 consecutive 32-bit outputs are enough to reconstruct that entire internal state, after which every subsequent output is predictable exactly. Nothing about the Mersenne Twister’s internal recurrence needs reproducing here to make the point land; the fact that 624 outputs fully determine the state is the whole story.
Here’s what that looks like away from security tokens entirely: an online card game shuffles decks client-side using a Mersenne-Twister-backed shuffle. A player scripts a bot that joins hundreds of hands, records the dealt card order each round, and once enough hands have gone by, reconstructs the shuffler’s full internal state from that recorded output. From that point on, the bot predicts every future deal before a single card is shown, turning a shuffle everyone assumed was fair into one that’s fully known in advance to exactly one player at the table.
To make “linear equals predictable” concrete without touching the Mersenne Twister’s actual recurrence, here’s a small invented toy: a 3-word (24-bit total), pure XOR/shift generator. Its next-state and output functions use only XOR and zero-fill shifts, no addition, no OR, which keeps the entire thing linear over GF(2). The attack script below observes 6 rounds of output (48 bits of equations for 24 unknown bits), builds the linear system via Gaussian elimination over GF(2), recovers the internal state, and then predicts outputs the “attacker” never saw:
import random
MASK = 0xFF
def update(state):
a, b, c = state
new_a = b
new_b = c
new_c = (a ^ ((b << 1) & MASK) ^ (c >> 2)) & MASK
return (new_a, new_b, new_c)
def output(state):
a, b, c = state
return (a ^ (b >> 1) ^ (c << 1)) & MASK # one byte of "random" output
def run_generator(state, n):
outs = []
s = state
for _ in range(n):
outs.append(output(s))
s = update(s)
return outs
N_BITS = 24 # 3 words * 8 bits
BASIS = []
for i in range(N_BITS):
word = i // 8
bit = i % 8
st = [0, 0, 0]
st[word] = 1 << bit
BASIS.append(tuple(st))
ROUNDS_NEEDED = 6 # 6 rounds * 8 bits/round = 48 equations for 24 unknowns
basis_outputs = [run_generator(b, ROUNDS_NEEDED) for b in BASIS]
random.seed(1234)
secret_state = (random.randint(0, 255), random.randint(0, 255), random.randint(0, 255))
observed = run_generator(secret_state, ROUNDS_NEEDED + 2)
known_outputs = observed[:ROUNDS_NEEDED]
future_outputs = observed[ROUNDS_NEEDED:]
rows, rhs = [], []
for t in range(ROUNDS_NEEDED):
for bitpos in range(8):
row = 0
for i in range(N_BITS):
if (basis_outputs[i][t] >> bitpos) & 1:
row |= (1 << i)
rows.append(row)
rhs.append((known_outputs[t] >> bitpos) & 1)
def solve_gf2(rows, rhs, n_unknowns):
rows, rhs = rows[:], rhs[:]
m = len(rows)
pivot_row_for_col = {}
r = 0
for col in range(n_unknowns):
piv = None
for i in range(r, m):
if (rows[i] >> col) & 1:
piv = i
break
if piv is None:
continue
rows[r], rows[piv] = rows[piv], rows[r]
rhs[r], rhs[piv] = rhs[piv], rhs[r]
for i in range(m):
if i != r and ((rows[i] >> col) & 1):
rows[i] ^= rows[r]
rhs[i] ^= rhs[r]
pivot_row_for_col[col] = r
r += 1
x = 0
for col, row_idx in pivot_row_for_col.items():
if rhs[row_idx]:
x |= (1 << col)
return x
recovered_bits = solve_gf2(rows, rhs, N_BITS)
recovered_state = (recovered_bits & 0xFF, (recovered_bits >> 8) & 0xFF, (recovered_bits >> 16) & 0xFF)
print(f"real secret initial state: {secret_state}")
print(f"recovered initial state: {recovered_state}")
print(f"match: {recovered_state == secret_state}")
predicted_future = run_generator(recovered_state, ROUNDS_NEEDED + 2)[ROUNDS_NEEDED:]
print(f"actual next outputs: {future_outputs}")
print(f"predicted next outputs: {predicted_future}")
print(f"predictions match: {predicted_future == future_outputs}")
real secret initial state: (225, 59, 3)
recovered initial state: (225, 59, 3)
match: True
actual next outputs: [79, 171]
predicted next outputs: [79, 171]
predictions match: True
Six rounds of output, 48 linear equations, and the “attacker” recovers the exact 24-bit secret state and correctly predicts the next two outputs before ever seeing them. That’s the entire linearity weakness made tangible: no guessing, no brute force, just solving equations.
flowchart TD
subgraph Non-crypto PRNG
S1["Internal state"] -->|"XOR / shift only\n(linear)"| O1["Output stream"]
O1 -->|"enough samples"| E1["System of linear\nequations, solvable"]
E1 --> P1["Full state recovered\nfuture predicted"]
end
subgraph CSPRNG
S2["Internal state"] -->|"one-way function"| O2["Output stream"]
O2 -.->|"no feasible inverse"| X2["State stays hidden"]
end
Getting Randomness From the Operating System
All of the above is architecture. In practice, developers don’t build PRNGs, they call one the operating system already provides. On Linux, the interface to reach for is /dev/urandom, backed directly by the kernel’s CSPRNG, and it is the correct choice for cryptographic use. /dev/random exists alongside it and additionally tries to estimate how much “true” entropy is currently available, blocking callers when that estimate reads low. That design is now widely considered a mistake: the estimate itself is unreliable, and the blocking behavior just causes hang-style denial of service once the underlying CSPRNG is already properly seeded, which on any long-running system it is. getrandom() is the modern syscall equivalent, and it only blocks briefly, and only before the system has ever been seeded for the very first time; after that first seeding it never blocks again.
Windows applications call BCryptGenRandom(), the current recommended API, replacing the legacy CryptGenRandom(), both backed by a kernel-mode entropy collector. Separately, Intel chips since 2012 expose RDRAND, an instruction that samples on-chip thermal noise directly into software, entirely independent of the OS’s own CSPRNG. It’s a useful additional input but a risky sole source: a single hardware RNG is a single point of failure, if it’s ever flawed, biased, or backdoored, anything relying on it exclusively inherits that flaw silently. Best practice folds RDRAND output into other entropy sources rather than trusting it alone.
Here’s where a strong CSPRNG gets quietly weakened by integration, not by algorithm choice. Picture a logging library’s randomness wrapper: it opens /dev/urandom once at process start, reads 32 bytes into a module-level buffer, and then, for the entire lifetime of the process, reuses that same cached buffer to build every “random” trace ID request comes in for. Every trace ID that process ever emits ends up identical. Worse, a second process started at nearly the same moment on the same machine, under similar boot-time entropy conditions, can end up caching an identical 32-byte buffer of its own, producing the exact same “random” trace IDs as a completely unrelated process. The CSPRNG behind /dev/urandom never failed. The bug is that caching randomness is functionally the same mistake as not generating it at all, the fix isn’t a better generator, it’s calling it again.
The demo below shows the right way to actually pull randomness from the command line: eyeballing a quick random-looking string with head, then generating a real 256-bit key the way an application should:
flowchart LR
Noise["Thermal noise, interrupt\njitter, RDRAND, etc."] --> Kernel["Kernel CSPRNG"]
Kernel --> Urandom["/dev/urandom\n(never blocks after boot)"]
Kernel --> Getrandom["getrandom()\n(blocks only pre-first-seed)"]
Kernel --> Random["/dev/random\n(blocks on low estimate,\nnow considered a design mistake)"]
Urandom --> App["Application randomness\n(correct choice)"]
Getrandom --> App
Random -.->|"avoid"| Hang["Possible hang / DoS"]
Real-World Randomness Failures
Three of these have actually happened, publicly, at real companies, with real consequences. Each one maps to a different failure class covered above.
Insufficient seed entropy at generation time: Debian OpenSSL, 2008. A Debian maintainer, trying to silence a memory-analysis tool warning, patched OpenSSL’s package in a way that accidentally stripped out nearly every entropy source feeding its seed. For roughly two years, every SSH and TLS key generated by that package, on any affected Debian or Debian-derived system, came from a seed pool small enough to enumerate: about 32,768 possible values per key type and processor architecture. Anyone could pre-generate every key a vulnerable system could possibly produce and simply check new keys against that list. The cipher, the key size, the math were all fine. The entropy feeding the seed was gone.
Choosing an algorithm without the right security property: Sony PlayStation 3, 2010. Sony signed PS3 firmware updates with ECDSA, an algorithm whose security depends entirely on using a fresh, never-repeated random nonce for every single signature. Sony’s implementation used the same nonce for every signature it ever produced. ECDSA math makes that fatal: two signatures sharing a nonce let an attacker set up a simple equation and solve directly for Sony’s private signing key. Once that key leaked publicly in 2010, anyone could sign arbitrary firmware as if they were Sony, no exploit needed against the console itself, just algebra applied to a nonce-reuse mistake.
Correct entropy, flawed transformation: Android SecureRandom, 2013. A bug in the Java SecureRandom implementation shipped on many Android devices sometimes left it poorly seeded after a device reboot, leading it to produce predictable or outright repeated output. Several Bitcoin wallet apps for Android fed that output straight into the per-transaction random nonce ECDSA needs to sign each transaction. Affected users ended up reusing a nonce across two or more transactions, exactly the Sony PS3 failure mode again, except this time attackers could just watch the public Bitcoin blockchain, spot the reuse pattern, run the same equation, recover victims’ private keys, and drain their wallets.
Here’s the shared math underneath both the Sony and Android incidents, shown symbolically, not lifted from any real codebase: an ECDSA signature is a pair (r, s), where r comes from a random nonce k, and s is computed from the message hash, k, and the private key d. Sign two different messages m1 and m2 with the same k, and both signatures share the same r:
signature 1: (r, s1) over message m1, using nonce k
signature 2: (r, s2) over message m2, using the SAME nonce k
s1 - s2 = k^-1 * (m1 - m2) (mod n)
=> k = (m1 - m2) * (s1 - s2)^-1 (mod n)
once k is known:
=> d = (s1*k - m1) * r^-1 (mod n) # private key recovered
Two signatures, one shared nonce, and the private key falls out of a handful of modular arithmetic operations. No factoring, no brute force, nothing exotic, just the exact algebra ECDSA’s nonce requirement exists to prevent.
flowchart TD
A["2008: Debian OpenSSL\nweak seed entropy"] --> A1["~32,768 enumerable keys\nper type/architecture"]
B["2010: Sony PS3\nECDSA static nonce"] --> B1["Private signing key\nrecovered via algebra"]
C["2013: Android SecureRandom\nnonce reuse in wallets"] --> C1["Bitcoin private keys\nrecovered from public chain"]
A1 & B1 & C1 --> D["Same root cause shape:\nrandomness assumption broken\nsomewhere in the pipeline"]
The Modulo-Bias Trap: When Strong Randomness Still Isn’t Uniform
Here’s the failure mode that survives even a perfectly-seeded CSPRNG, one that lives entirely downstream of the entropy source, in how random bytes get turned into a smaller range. The common task: you have a random byte, 0-255, and you need a value in some smaller range, say 0-25 for a letter of the alphabet, and the obvious tool is % 26. That obvious tool is subtly wrong whenever 256 doesn’t divide evenly by the target size, which for 26 it doesn’t: 256 = 9×26 + 22. That remainder of 22 means 22 of the 26 possible outputs get an extra byte value mapping to them, while the other 4 get one fewer, and those 22 “extra” outputs land measurably more often over enough draws.
Concretely: imagine a gift-code generator producing 8-character codes from uppercase letters A-Z, one letter per character via byte % 26. Since 256 = 9×26 + 22, letters A through V (the first 22 letters) each get 10 distinct byte values mapping to them, a probability of 10/256 ≈ 3.91%. Letters W through Z get only 9 byte values each, 9/256 ≈ 3.52%. That gap, 3.91% versus 3.52%, is small enough to sail past casual eyeballing of a handful of codes, and real enough to visibly skew outcomes once millions of codes get issued.
The simulation below runs exactly that scenario at scale: 1,000,000 simulated bytes reduced mod 26 the naive way, then the same million redone with rejection sampling, discarding any byte at or above 234 (the largest multiple of 26 under 256) and redrawing instead of reducing it:
import random
import string
N = 1_000_000
letters = string.ascii_uppercase # A-Z, 26 letters
def naive_letter():
b = random.randint(0, 255)
return letters[b % 26]
def rejection_letter():
# 256 = 9*26 + 22, so largest multiple of 26 under 256 is 234.
# discard any byte >= 234 and redraw.
while True:
b = random.randint(0, 255)
if b < 234:
return letters[b % 26]
naive_counts = {c: 0 for c in letters}
rej_counts = {c: 0 for c in letters}
for _ in range(N):
naive_counts[naive_letter()] += 1
rej_counts[rejection_letter()] += 1
print("letter naive_mod rejection")
for c in letters:
print(f" {c} {naive_counts[c]:7d} {rej_counts[c]:7d}")
letter naive_mod rejection
A 39115 38481
B 39121 38669
C 39424 38389
D 38873 38325
E 38982 38687
F 39161 38422
G 38915 38240
H 39168 38379
I 38986 38387
J 38955 38151
K 38699 38668
L 39270 38465
M 38792 38179
N 39293 38462
O 38934 38707
P 38717 38456
Q 39606 38523
R 38731 38122
S 39048 38617
T 38780 38552
U 39321 38503
V 39267 38313
W 35142 38565
X 35322 38779
Y 35198 38440
Z 35180 38519
Naive mod: A comes in at 39,115 out of a million draws while W comes in at 35,142, a gap of nearly 4,000 codes per million, matching the predicted 3.91% versus 3.52% almost exactly. Rejection sampling flattens the whole alphabet to a tight band around 38,300-38,780, with no A-through-V versus W-through-Z split left at all. The entropy source was identical, uniform Python random.randint(0, 255) calls, in both runs. The only thing that changed was the reduction step, which is exactly why this bug is so easy to ship: it hides behind a perfectly strong CSPRNG the whole time.
flowchart LR
B["Random byte\n0-255"] --> M["% 26"]
M --> Skew["A-V: 10 byte values each\n(3.91%)\nW-Z: 9 byte values each\n(3.52%)"]
B --> R["Reject if >= 234"]
R -->|"redraw"| B
R -->|"< 234"| M2["% 26"]
M2 --> Flat["All letters: exactly\n9 byte values each\n(uniform)"]
Best Practices Checklist for Developers
Everything above compresses into five rules:
- Never use a general-purpose
random/rand/Math.random-style function for anything security-relevant. Reach for the platform’s crypto API instead:secretsin Python,crypto.randomBytesin Node.js,SecureRandomin Java,/dev/urandom,getrandom(), orBCryptGenRandom()at the OS level. - Never hand-roll a PRNG, or reimplement a “simple” random algorithm, for crypto use. Even algorithmically sound designs like Fortuna are easy to get subtly wrong in integration details that don’t show up until they’re exploited.
- Always check the return value and freshness of every randomness call. A silent failure or a stale cached buffer turns a strong CSPRNG call into predictable garbage without any visible error.
- Never reuse a nonce, IV, or seed across two invocations, and never ship a hardcoded or shared seed file across device instances.
- When converting random bytes into a bounded range, dice rolls, digits, letters, shuffles, always use rejection sampling or an equivalent unbiased-reduction technique instead of naive modulo.
Mapped back to the incidents this post covered: rule 1 and proper entropy sourcing would have prevented Debian’s 2008 weak-key bug. Rule 4 would have prevented both Sony’s PS3 key leak and the Android wallet thefts, in both cases the failure was literally “the same nonce twice.” Rule 5 is the modulo-bias trap, in full.
Here’s the closing reference card, actually run, both languages:
import random, secrets
print('random.random(): ', random.random())
print('secrets.token_bytes(16):', secrets.token_bytes(16).hex())
random.random(): 0.8873665250112432
secrets.token_bytes(16): d94bfd46a75961d06105eaf68a384c26
console.log('Math.random(): ', Math.random());
const crypto = require('crypto');
console.log('crypto.randomBytes(16):', crypto.randomBytes(16).toString('hex'));
Math.random(): 0.3742794469430333
crypto.randomBytes(16): 6bd5876fe0e9efb117d91a58044e322d
Both random.random() and Math.random() look perfectly random sitting on a terminal. Neither is safe to use for a key, a token, or a nonce. That’s the entire lesson of this post in two lines of output: looking random and being cryptographically unpredictable are different properties, and only one of them is the one that matters here.