Secure client-side random range generation: Avoiding modulo bias in JavaScript bounded intervals
The Modulo Trap: Why Your "Random" Winner Isn't Random
You've built a client-side raffle tool. Ten thousand entries. One winner. The code looks clean: crypto.getRandomValues(new Uint8Array(1))[0] % 10000. You ship it feeling confident because you used the Web Crypto API instead of Math.random(). Here's the problem: your raffle is biased, and entries near the bottom of your list are quietly more likely to be selected than entries near the top.
This is modulo bias — one of the most common silent failures in client-side random number generation. Developers reach for the modulo operator (%) to map a raw random byte into a bounded interval, assuming the remainder of a division is uniformly distributed. It isn't, at least not when the range doesn't evenly divide the source space. Let's walk through why this happens, how to measure it, and how to eliminate it with a technique called rejection sampling. We'll approach this as if we're building that raffle tool from scratch, correcting each mistake along the way.
Understanding the Math Behind Modulo Bias
The Source Space Problem
When you call crypto.getRandomValues(new Uint8Array(1)), you get a value between 0 and 255 — 256 possible outcomes, each equally likely. Now suppose your raffle has only 10 entries, so you want a random number between 0 and 9. You write value % 10.
Here's what that actually produces:
- Outcomes 0 through 5 each receive 26 source values (0 maps from 0, 10, 20, … 250; 1 maps from 1, 11, 21, … 251; and so on through 5 mapping from 5, 15, … 255). - Outcomes 6 through 9 each receive only 25 source values (6 maps from 6, 16, … 246; the next would be 256, which doesn't exist).
That means entry 0 has a 26-in-256 chance (10.16%) while entry 6 has a 25-in-256 chance (9.77%). Entry 0 is 4% more likely to win than entry 6. In a raffle with real prizes, that's not acceptable.
Why Severity Depends on the Ratio
The bias magnitude depends on how cleanly your range divides into the source space. With a Uint8Array (256 values) and a range of 3, value 0 gets 86 source values while values 1 and 2 get 85 each — a roughly 1.2% skew. Small, but systematic. Scale up to a Uint32Array with its 4,294,967,296 possible values, and the bias for most practical ranges becomes vanishingly small in relative terms. But "vanishingly small" and "zero" are different things. When fairness is the entire point of your application, you need zero.
Step 1: Choosing the Right Entropy Source
Before fixing the bias, let's correct a more fundamental mistake. Many developers use Math.random() for raffles and giveaways. This function relies on a non-cryptographic PRNG — typically a xorshift variant — that is predictable if an attacker can observe enough consecutive outputs. For a raffle where someone could inspect the page source and infer the PRNG state, that's a real vulnerability.
The correct source is crypto.getRandomValues(), available in all modern browsers through the Web Crypto API. It draws from the operating system's CSPRNG (such as /dev/urandom on Linux or BCryptGenRandom on Windows), which is suitable for security-sensitive operations.
Here's the foundation:
const buffer = new Uint32Array(1);
crypto.getRandomValues(buffer);
const rawValue = buffer[0]; // 0 to 4,294,967,295
I'm using Uint32Array rather than Uint8Array because the larger source space (2³² versus 2⁸) dramatically reduces the rejection rate in our sampling loop, which we'll build shortly. Fewer rejections means fewer calls to the CSPRNG, which matters when you're running multiple draws in sequence.
Step 2: Calculating the Rejection Threshold
This is where most tutorials hand-wave, so let's be precise. The goal of rejection sampling is to discard raw values that would contribute to bias, keeping only those that map uniformly onto the target range.
Given a source space of size S (for Uint32Array, S = 2³² = 4,294,967,296) and a target range R, we find the largest multiple of R that fits within S. Call it L:
L = S - (S % R)
Any raw value below L is safe to use with modulo. Any raw value at or above L must be rejected and re-sampled. Let's plug in real numbers for our 10,000-entry raffle:
- S = 4,294,967,296 - R = 10,000 - S % R = 7,296 (because 429,496 × 10,000 = 4,294,960,000, and the remainder is 7,296) - L = 4,294,967,296 − 7,296 = 4,294,960,000
The rejection zone is the range 4,294,960,000 through 4,294,967,295 — that's 7,296 values out of 4.3 billion. The probability of rejection on any single draw is 7,296 ÷ 4,294,967,296, approximately 0.00017%. In practice, you'll almost never reject. But the guarantee is what matters: when you don't reject, you know your distribution is perfectly uniform.
Step 3: Building the Rejection Sampling Loop
Now we assemble the full function. The logic is simple: generate, check against the threshold, retry if needed.
function secureRandomInt(maxExclusive) {
// Returns a uniform integer in [0, maxExclusive)
const sourceSpace = 0x100000000; // 2^32
const threshold = sourceSpace - (sourceSpace % maxExclusive);
const buffer = new Uint32Array(1);
while (true) {
crypto.getRandomValues(buffer);
if (buffer[0] < threshold) {
return buffer[0] % maxExclusive;
}
// Value fell in the rejection zone — try again
}
}
Handling Inclusive Bounds
If your raffle API needs to return a number in [min, max] inclusive, adjust the range before passing it in:
function secureRandomIntInclusive(min, max) {
const range = max - min + 1;
return min + secureRandomInt(range);
}
For 10,000 entries indexed 0 through 9999, you'd call secureRandomInt(10000) and use the result directly as an array index.
Verifying Uniformity: A Practical Bias Test
You shouldn't trust any RNG implementation blindly — including the one above. Here's a simple frequency test you can run in your browser console to empirically verify uniformity. Generate one million samples for a small range and count how often each outcome appears:
const counts = new Array(10).fill(0);
for (let i = 0; i < 1_000_000; i++) {
counts[secureRandomInt(10)]++;
}
console.log(counts);
// Expected: each bucket ≈ 100,000 ± ~300
Run the same test with the naive approach — crypto.getRandomValues(new Uint8Array(1))[0] % 10 — and you'll see buckets 0 through 5 hovering around 101,562 each, while buckets 6 through 9 sit near 97,656 each. That roughly 4% gap is the modulo bias made visible in real numbers.
With the rejection sampling function, all ten buckets should land within a few hundred of 100,000. The remaining variance is expected statistical noise, not systematic skew. That difference — between noise and bias — is the entire point of this exercise.
Production Considerations for Client-Side Deployment
Performance Under Load
The while (true) loop looks alarming at first glance. Let's quantify the actual risk. For any range R where R is much smaller than 2³², the rejection probability is (S % R) / S, which is negligible. Even in the theoretical worst case — where R is just over half of 2³² — the rejection rate never exceeds 50%, so the expected number of iterations is at most 2. For typical raffle sizes (hundreds to millions of entries), you'll return on the first call virtually every time.
Fallback for Legacy Environments
crypto.getRandomValues() is available in all browsers released after 2015. If you must support truly ancient environments, detect and degrade gracefully:
function getCryptoSource() {
if (typeof crypto !== 'undefined' && crypto.getRandomValues) {
return crypto.getRandomValues.bind(crypto);
}
console.warn('Web Crypto API unavailable. Output is NOT cryptographically secure.');
return (arr) => {
for (let i = 0; i < arr.length; i++) {
arr[i] = Math.floor(Math.random() * 256);
}
return arr;
};
}
This fallback does not provide cryptographic security. If your application genuinely requires it — raffles with physical prizes, token generation, access codes — you should block execution and display a warning rather than silently downgrading. A user who doesn't know their r