“We used a random number” is not yet an explanation.
A product team is selecting one of eight accounts for a small promotional credit. Their helper asks a random-number API for a value from 0 through 7, then uses that value as an array index. A unit test passes locally and fails in CI because the selected account differs. Separately, a security review asks whether the same helper can issue invitation tokens that outsiders cannot guess.
The test’s changing result can be normal: a randomized test has no fixed sequence unless its inputs are controlled. The token question is different: a value that is merely hard to predict by a test runner may still be guessable by an attacker. We should trace the path from source to decision instead of treating “random” as a single property.
- Decision
- Choose one of eight eligible accounts.
- Observed issue
- A test expects a repeatable result; its current draw changes.
- Second use
- Generate an invitation token that grants access when redeemed.
- Unknowns
- Generator, seed, range mapping, token lifetime, and retry limits.
A seed controls a sequence; it does not add secrecy.
A pseudorandom number generator (PRNG) is an algorithm with internal state. Given the same algorithm and initial seed, it produces the same sequence. This makes a seeded PRNG useful for reproducing a randomized test: save the seed with the failure and replay the sequence while you inspect which branch was taken. The sequence can look irregular while still being completely determined by its state.
A cryptographically secure pseudorandom number generator (CSPRNG) is designed so outputs remain computationally difficult to predict without secret state, even when an attacker sees other outputs. Operating systems expose such a source through platform APIs. Application code should use the approved API rather than inventing an entropy source from the clock, process ID, a counter, or a general-purpose seeded PRNG.
This distinction is about purpose, not visual quality. A test may need an exact replay more than it needs unpredictability. A password reset token needs unpredictability and enough possible values that online guesses are infeasible under the system’s controls. A randomized test can also run multiple seeds in CI, but it should print and retain the seed that reproduces any failure.
| Task | Useful property | Evidence to keep |
|---|---|---|
| Reproduce a randomized test | Same seed and algorithm repeat the sequence. | Seed, generator version, test inputs, and failing step. |
| Simulate a model | Controlled streams support comparable runs. | Seed, model assumptions, and summary across many runs. |
| Issue a reset token | Unpredictability against an attacker. | Platform CSPRNG API, token handling, and access controls. |
| Pick a winner | Uniform selection over eligible entries, with auditability. | Eligibility snapshot, mapping method, and any required audit record. |
Remainder mapping can make some outcomes more common.
Suppose a toy source can return each integer from 0 through 7 with equal probability. The
team wants one of three outcomes and writes source % 3. Eight equally likely
inputs do not split evenly into three groups: residues 0 and 1 each have three inputs;
residue 2 has two. The first two outcomes are therefore each selected with probability 3/8, while the third is selected with probability 2/8. This is
modulo bias.
The diagnosis is a counting argument. If a source has N equally likely values
and we want k outcomes, then simple remainder mapping is even only when N is divisible by k. Keep the largest prefix whose size is divisible by the
bound: limit = floor(N / k) × k. Reject source values at or above that limit; each
remaining residue then has exactly limit / k preimages.
For the toy 0–7 source and three outcomes, limit = floor(8 / 3) × 3 = 6.
Discard 6 and 7, then apply remainder to 0–5. Each outcome appears exactly twice. Real
library helpers use this kind of rejection logic or an equivalent method, so prefer a
vetted bounded-random API when one is available. Never assume `% bound` makes a range
uniform.
A fair shuffle needs one fair bounded choice at each step.
A common mistake is to visit every position and swap it with any position in the whole
array. That does not produce every permutation with the same probability. For three
elements, there are 3³ = 27 possible sequences of choices, while there are
only 3! = 6 permutations; 27 cannot divide evenly by 6, so some orders necessarily arise more often.
The Fisher–Yates algorithm fixes the choice set as the work shrinks. Starting at the last
index i, choose j uniformly from 0…i, swap those entries,
then move to i − 1. At each step, the selected item is fixed into its final
position. For n items there are n × (n−1) × … × 1 = n! possible
choice paths, and each path corresponds to one permutation. If each bounded choice is
uniform and independent, each permutation has probability 1 / n!.
For a three-item list [A, B, C], choose an index in 0…2 and place that item
last; then choose in 0…1 and place that item next-to-last. The final remaining item is
fixed. The choice ranges shrink with the unplaced prefix; this is why a uniform bounded
sampler belongs inside the algorithm.
- Draw 1
- Choose j in 0…2; swap the chosen item with index 2.
- Draw 2
- Choose j in 0…1; swap the chosen item with index 1.
- Finish
- Index 0 is the last remaining item; no draw is needed.
- Fairness condition
- Each draw is uniform over its current inclusive range.
Change the source size and count how many raw values each outcome receives.
This finite-source lab counts possible inputs exactly. It is not a random simulation and does not measure a real generator. A small source makes the mapping visible: compare all raw values under remainder mapping with the accepted prefix used by rejection sampling.
Count of raw values assigned to outcomes 0 through 2.
Accept raw values below 6; discard 2 value(s), then take remainder.
Formula: accepted limit = floor(N ÷ k) × k = 2 × 3 = 6. Each accepted outcome has 2 raw preimage(s).
Use secure bytes, remove the incomplete tail, then run Fisher–Yates.
The examples use the operating system's cryptographic random source for a bounded integer
and a shuffle. The TypeScript helper draws a 32-bit word and rejects the incomplete tail
before taking a remainder. The Go example delegates bounded selection to crypto/rand.Int, which returns a value in [0, bound). Both validate the bound and propagate
failure. A security-sensitive caller must handle source errors; it should not silently
fall back to a seeded generator.
The PRNG snippet is deliberately separate and only demonstrates deterministic replay. Keep its state and output away from secrets. For robust application code, prefer standard-library seeded generators for simulation unless cross-version sequence stability is part of the contract; if it is, specify and version the generator explicitly.
Trace the accepted range, the inclusive shuffle bound, and the error path.
/**
* Draw an unbiased integer in [0, bound) from browser cryptographic randomness.
* Rejection removes the incomplete tail that would make `% bound` biased.
*/
export function secureBelow(bound: number): number {
const range = 2 ** 32;
if (!Number.isSafeInteger(bound) || bound < 1 || bound > range) {
throw new RangeError('bound must be an integer from 1 through 2^32');
}
const limit = Math.floor(range / bound) * bound;
const word = new Uint32Array(1);
let value: number;
do {
globalThis.crypto.getRandomValues(word);
value = word[0]!;
} while (value >= limit);
return value % bound;
}
/** Fisher–Yates using cryptographically strong, unbiased bounded draws. */
export function secureShuffle<T>(items: readonly T[]): T[] {
const result = [...items];
for (let i = result.length - 1; i > 0; i -= 1) {
const j = secureBelow(i + 1);
[result[i], result[j]] = [result[j]!, result[i]!];
}
return result;
}
/**
* A tiny deterministic source for replayable examples only. This is not a
* cryptographic generator. A zero seed is rejected because xorshift would
* remain at zero forever.
*/
export function xorshift32(seed: number): () => number {
if (!Number.isInteger(seed) || seed < 1 || seed > 0xffff_ffff) {
throw new RangeError('seed must be a non-zero uint32');
}
let state = seed >>> 0;
return () => {
state ^= state << 13;
state ^= state >>> 17;
state ^= state << 5;
return state >>> 0;
};
}
const draw = xorshift32(42);
console.log('Replayable example; never use for secrets:', [draw(), draw(), draw()]);
package main
import (
"crypto/rand"
"fmt"
"math/big"
)
// secureBelow returns an unbiased integer in [0, bound). crypto/rand.Int
// uses rejection sampling so a non-dividing bound does not inherit modulo bias.
func secureBelow(bound int) (int, error) {
if bound < 1 {
return 0, fmt.Errorf("bound must be positive")
}
n, err := rand.Int(rand.Reader, big.NewInt(int64(bound)))
if err != nil {
return 0, fmt.Errorf("secure random draw: %w", err)
}
return int(n.Int64()), nil
}
// secureShuffle applies Fisher–Yates with one uniform bounded draw per step.
func secureShuffle(items []string) error {
for i := len(items) - 1; i > 0; i-- {
j, err := secureBelow(i + 1)
if err != nil {
return err
}
items[i], items[j] = items[j], items[i]
}
return nil
}
// xorshift32 is a tiny deterministic source for replayable examples only.
// It is not cryptographically secure. A non-zero seed is required.
func xorshift32(seed uint32) func() uint32 {
if seed == 0 {
panic("xorshift32 seed must be non-zero")
}
state := seed
return func() uint32 {
state ^= state << 13
state ^= state >> 17
state ^= state << 5
return state
}
}
func main() {
items := []string{"alpha", "bravo", "charlie", "delta"}
if err := secureShuffle(items); err != nil {
// A security-sensitive path should surface the error; do not fall back
// to a predictable generator.
panic(err)
}
fmt.Println("Secure shuffle:", items)
draw := xorshift32(42)
fmt.Println("Replayable example; never use for secrets:", draw(), draw(), draw())
}
Diagnose the whole path before deciding the random source is at fault.
When a randomized behavior surprises you, gather evidence in this order:
- State the requirement. Is the need replayability, statistical behavior, secrecy, or uniqueness?
- Record the source. Which API and algorithm ran, with what seed or OS source, and did errors propagate?
- Trace the mapping. What is the raw range, requested interval, rejection rule, and endpoint convention?
- Inspect the surrounding system. Are draws concurrent, retried, truncated, logged, persisted, or checked for collisions?
- Test the claim that could fail. Reproduce from a saved seed for tests; review code and threat controls for secrets; use statistical checks as diagnostics, not proof.
A playlist randomizer repeats the same opening song.
List at least three plausible causes before proposing a fix. How would you distinguish a seed-reset bug, a biased shuffle, a small playlist, and a product rule that avoids recent songs? What would you log or simulate, and which requirement—replayability, uniformity, or security—does the playlist actually need?
- MDN: Crypto.getRandomValues() and the Web Cryptography specification describe cryptographic random values for web applications.
- MDN: Math.random() documents its non-cryptographic security boundary.
- Go standard library: crypto/rand.Int documents a uniform value in [0, max).
- Go standard library: math/rand documents pseudorandom generators for simulation and non-security use.
- Richard Durstenfeld, “Algorithm 235: Random permutation”, Communications of the ACM 7(7), 1964.