Stop Trusting Math.random: Secure RNG Always Prevails
The Night 4,200 Gamers Lost Faith in a Raffle
Last October, a friend of mine ran a community giveaway for his Discord server. Four thousand two hundred entries. Three winners. He built a slick single-page app, wired up Math.random() to pick the lucky numbers, and hit "Draw" live on stream. Within forty-eight hours, two users had reverse-engineered his seeding pattern and proven—mathematically—that the draw wasn't uniform. One of them wasn't even a winner; he was just offended on principle.
That moment crystallized something most lottery app tutorials gloss over: Math.random() is fine for shuffling a playlist, and catastrophic for anything resembling a fair lottery draw.
Let's walk through why that is, what the safer alternative looks like, and how to actually implement it on the client side without pulling in a 200KB cryptography library.
The Misconception: "Random Is Random"
Here's the trap. Math.random() feels random. It returns a float between 0 and 1. It passes the eyeball test. You multiply it by your entry count, floor it, and boom—winner.
But under the hood, most JavaScript engines implement it as a PRNG—a pseudo-random number generator—typically using something like xorshift128+ (V8) or a similar algorithm. These are designed for speed, not unpredictability. They're deterministic. Given the same internal state, they produce the exact same sequence of outputs every time.
Why Determinism Kills Lottery Fairness
For a raffle to be fair, two properties matter:
- Uniformity — every entry has equal probability of being selected.
- Unpredictability — no participant can guess or influence the outcome ahead of time.
Math.random() roughly satisfies the first (within the limits of its algorithm's distribution quality). It completely fails the second. Its internal state can sometimes be inferred from observed outputs. Researchers have demonstrated that as few as 5-10 consecutive outputs from xorshift128+ can be enough to reconstruct the full state and predict future draws. In a lottery context, that's not a quirk—it's a vulnerability.
The Correction: Switching to a CSPRNG
The alternative isn't exotic. The Web Crypto API has shipped in every major browser for years, and it exposes crypto.getRandomValues()—a interface to the operating system's cryptographically secure pseudo-random number generator (CSPRNG).
The contrast is stark:
| Math.random() | crypto.getRandomValues() | |
|---|---|---|
| Algorithm | xorshift128+ (V8) | OS entropy pool (e.g., /dev/urandom) |
| Predictable? | Yes, state can be reconstructed | No, designed to resist prediction |
| Speed | ~10ns per call | ~100-500ns per call |
| Use case | UI effects, simulations | Tokens, lottery draws, keys |
That 10-50x speed difference? Irrelevant for a lottery draw picking 3 winners from 4,200 entries. You're making a handful of calls. The security trade-off is a no-brainer.
Step-by-Step: A Secure Client-Side Lottery Draw
Here's how to actually build it. I'll use the same scenario: 4,200 entries, 3 winners, no duplicates.
Step 1: Generate a Secure Random Index
function secureRandomInt(maxExclusive) {
const maxUint32 = 0xFFFFFFFF;
const limit = maxUint32 - (maxUint32 % maxExclusive);
const buf = new Uint32Array(1);
let val;
do {
crypto.getRandomValues(buf);
val = buf[0];
} while (val >= limit);
return val % maxExclusive;
}
That modulo-rejection loop looks fussy, but it's critical. If you just did crypto.getRandomValues(buf)[0] % maxExclusive, you'd introduce modulo bias—entries near the bottom of the range get a slightly higher probability. With 4,200 entries and a 32-bit source, the bias is tiny (roughly 0.0000002%), but in a fairness-critical context, "tiny" isn't "zero." The rejection loop discards values that would skew the distribution and is standard practice in secure RNG implementations.
Step 2: Draw Without Replacement
function drawWinners(entries, count) {
const pool = [...entries];
const winners = [];
for (let i = 0; i < count; i++) {
const idx = secureRandomInt(pool.length);
winners.push(pool[idx]);
pool.splice(idx, 1);
}
return winners;
}
Simple. Each draw shrinks the pool, so no entry can win twice. For 4,200 entries and 3 winners, this runs in microseconds.
Step 3: Make It Verifiable
Here's where my friend's original app really fell apart. Even with a secure RNG, if participants can't verify the draw was fair, trust doesn't scale. The fix: commit to a seed before the draw, then reveal it after.
// Before the draw (publicly visible)
const seed = crypto.getRandomValues(new Uint8Array(32));
const commitment = await crypto.subtle.digest('SHA-256', seed);
publishToPage(hex(commitment)); // users can see this
// After the draw
publishToPage(hex(seed)); // users verify SHA-256(seed) === commitment
Now anyone can confirm the seed was fixed in advance. Combined with a deterministic shuffle algorithm seeded by that value, you get a draw that's both unpredictable and auditable. That's the gold standard for client-side lottery fairness.
What About the Edge Cases?
A few practical notes from hard experience:
Browser support. crypto.getRandomValues() works in every browser released after 2015. If you need to support ancient environments, feature-detect and fall back with a loud warning—not a silent Math.random() downgrade.
Performance anxiety. If you're generating thousands of values (say, shuffling a massive deck), batch them into a single Uint32Array buffer rather than calling the API per-value. One call filling a 4,096-element array is dramatically faster than 4,096 individual calls.
Server-side draws. If stakes are real money, client-side RNG isn't enough—someone can always tamper with the running JavaScript. Move the draw to a server (or better, a smart contract with commit-reveal). The techniques above are for trust-enhanced community raffles, not Powerball.
The Takeaway
My friend rebuilt his raffle app in an afternoon. The commit-reveal pattern cost him maybe forty extra lines of code. The next giveaway ran clean, and the server's most pedantic users—now able to verify the draw themselves—went quiet.
The lesson sits right at the heart of the RNG niche: randomness has grades. Math.random() is the cheap stuff. It works for confetti animations and dice-rolling demos. The moment fairness enters the conversation—money, prizes, reputation—you reach for the CSPRNG and you make the process auditable. The performance cost is negligible. The trust dividend is enormous. And the alternative, as 4,200 skeptical gamers can attest, is a raffle nobody believes in.
Frequently Asked Questions
Is JavaScript Math.random() secure for lottery draws?
No, Math.random() is completely unsafe for lottery draws or any security-sensitive applications. It uses a pseudo-random number generator (PRNG) that can be predictable and is not cryptographically secure, making it vulnerable to manipulation.
Why is Math.random() not cryptographically secure?
Math.random() relies on algorithms like Xorshift that produce predictable sequences based on an internal seed. If an attacker discovers the seed or observes enough outputs, they can accurately predict future winning numbers.
How do I generate a cryptographically secure random number in JavaScript?
You should use the Web Crypto API's crypto.getRandomValues() method instead of Math.random(). This function uses the operating system's secure random number generator to provide unpredictable, high-quality entropy.
Can I run a fair raffle entirely on the client-side?
Yes, you can run a fair raffle on the client-side if you use a Cryptographically Secure Pseudo-Random Number Generator (CSPRNG). By utilizing crypto.getRandomValues() and implementing a provably fair algorithm, users can verify the draw wasn't tampered with.
What is the difference between a PRNG and a CSPRNG?
A standard PRNG is designed for speed and statistical randomness, making it predictable if the internal seed is known. A CSPRNG is designed for security, using high-entropy sources to ensure the output cannot be predicted even if previous outputs are observed.
How do I securely shuffle an array for a lottery draw?
To securely shuffle an array, you must replace the standard Fisher-Yates shuffle's random index selection with crypto.getRandomValues(). This ensures the shuffling process relies on cryptographically secure entropy rather than predictable Math.random outputs.
Is crypto.getRandomValues() truly random?
While not true hardware randomness, crypto.getRandomValues() gathers entropy from unpredictable OS-level sources like mouse movements and system timings. This makes it sufficiently random and unpredictable for secure lottery draws and cryptographic operations.
How to prevent random number manipulation in a web lottery?
To prevent manipulation, never rely on Math.random() and always use crypto.getRandomValues() for generating draw numbers. Additionally, implementing a commit-reveal scheme allows participants to verify the draw integrity after the winners are selected.
What is provably fair technology in random generation?
Provably fair technology uses cryptographic hashes to prove a random draw was fair and unmanipulated. It allows users to verify that the server or client seed used in the CSPRNG was not altered after participants entered the lottery.
Does using crypto.getRandomValues() slow down my website?
No, crypto.getRandomValues() is highly optimized and runs efficiently in modern web browsers. While it is slightly slower than Math.random() due to the security overhead, the difference is imperceptible for standard lottery draw applications.