Secure RNG: Math.random Rigged Our Lottery Draw

Why did our community lottery draw get challenged the moment we used Math.random?

Last spring, I helped a small neighborhood association build a web app for their annual charity raffle. Nothing fancy — 412 tickets sold, 5 winners drawn, results posted on a static page. Simple, right? We used Math.random() to pick the winning ticket numbers, and within two hours of publishing results, a participant named Marcus emailed us asking for proof the draw wasn't rigged.

His argument was sharp: "You used JavaScript's built-in random function. Anyone who knows anything about V8's PRNG knows it's deterministic and seed-reconstructible. How do I know you didn't pre-pick the winners?"

He wasn't wrong. And that moment sent me down a rabbit hole I want to walk you through — because if you're building any kind of client-side lottery, raffle, or random selection tool, you'll face this exact challenge.

What makes Math.random() unsuitable for fair lottery draws?

Here's the thing I had to learn the hard way. Math.random() is a pseudo-random number generator (PRNG) — specifically, most modern JavaScript engines use a variant of xorshift128+. It's fast, it's lightweight, and it produces numbers that look random enough for animations, games, and UI shuffling.

But "looks random" and "is provably fair" are entirely different universes.

The core problems:

  • Predictability: Given enough outputs (sometimes as few as 5-6 consecutive values), the internal state of xorshift128+ can be reconstructed, allowing an attacker to predict future draws.
  • No cryptographic guarantees: The spec explicitly states Math.random() should not be used for cryptographic purposes. It was never designed to resist reverse engineering.
  • Seed exposure: In browser environments, the initial seed often derives from low-entropy sources like timestamps, making the output space smaller than the 2^128 theoretical maximum.

For our raffle with 412 tickets, an attacker wouldn't even need to break the full PRNG. They'd just need to observe the timing of the draw call and narrow the seed window. I ran the math afterward — with millisecond-precision timestamp seeding, there are roughly 1,000 plausible seed states per second. That's a brute-force attack that takes milliseconds, not hours.

How does the Web Crypto API solve the fairness problem?

When I started researching alternatives, every road led to the same destination: crypto.getRandomValues(). This method, part of the Web Cryptography API, taps into your operating system's cryptographically secure random number generator (CSPRNG) — the same entropy pool that secures TLS handshakes and SSH key generation.

What makes it different from Math.random() at a technical level?

Entropy sourcing

Instead of a deterministic algorithm seeded once at startup, crypto.getRandomValues() pulls from a continuously replenished entropy pool. On Linux, that's /dev/urandom. On macOS, it's SecRandomCopyBytes. On Windows, it's BCryptGenRandom. These sources gather environmental noise — hardware interrupts, thermal fluctuations, disk timing — making the output genuinely unpredictable rather than algorithmically generated.

Resistance to state reconstruction

Even if an attacker observes thousands of outputs from crypto.getRandomValues(), they cannot reconstruct the internal state to predict future values. The entropy pool is constantly mixed with new input, and the cryptographic primitives (typically AES-CTR or ChaCha20 in DRBG mode) ensure forward and backward secrecy.

What's the actual code to replace Math.random in a lottery draw?

Let me show you exactly what we shipped after Marcus's email. Here's the before-and-after.

The old, vulnerable approach:

function drawWinners(tickets, count) {
  const winners = [];
  const pool = [...tickets];
  for (let i = 0; i < count; i++) {
    const idx = Math.floor(Math.random() * pool.length);
    winners.push(pool.splice(idx, 1)[0]);
  }
  return winners;
}

The cryptographically secure replacement:

function secureDrawWinners(tickets, count) {
  const winners = [];
  const pool = [...tickets];
  const max = pool.length;
  
  for (let i = 0; i < count; i++) {
    // Generate a random value in [0, max) using rejection sampling
    const range = 2 ** 32;
    const limit = range - (range % max);
    let rand;
    
    do {
      const buf = new Uint32Array(1);
      crypto.getRandomValues(buf);
      rand = buf[0];
    } while (rand >= limit);
    
    const idx = rand % max;
    winners.push(pool.splice(idx, 1)[0]);
  }
  return winners;
}

Notice the rejection sampling loop. This is critical. If you simply do crypto.getRandomValues(buf)[0] % max, you introduce modulo bias — certain ticket numbers become slightly more likely than others. For our 412-ticket pool, naive modulo would make tickets 0–95 about 0.024% more likely than tickets 96–411. That doesn't sound like much, but in a fairness audit, it's a red flag.

The rejection sampling approach ensures a perfectly uniform distribution by discarding values that fall in the "remainder" zone and requesting fresh randomness.

Can you make a client-side draw provably fair to skeptical participants?

This was the question that kept me up at night. Even with crypto.getRandomValues(), how do you prove to Marcus that you actually ran the code as claimed? A black-box draw — even a secure one — still requires trust.

The answer is a commit-reveal scheme, and it transformed how our association runs raffles.

How commit-reveal works for lottery draws

Before the draw, you generate a random seed using crypto.getRandomValues() and publish its cryptographic hash (a commitment). Participants can see the hash but not the seed. After the draw, you reveal the seed, and anyone can verify that the hash matches and that the draw was computed deterministically from that seed.

// Commitment phase (before draw)
const seed = new Uint8Array(32);
crypto.getRandomValues(seed);
const commitment = await crypto.subtle.digest('SHA-256', seed);
const commitmentHex = [...new Uint8Array(commitment)]
  .map(b => b.toString(16).padStart(2, '0')).join('');

// Publish commitmentHex publicly
// ... draw happens using seed ...

// Reveal phase (after draw)
// Publish seedHex, let participants verify:
// SHA-256(seedHex) === commitmentHex
// drawResult === computeDraw(tickets, seedHex)

Now Marcus can verify three things independently: that the seed was committed before the draw, that the revealed seed matches the commitment, and that the winners were correctly derived from that seed. Zero trust required.

What browser compatibility issues should you watch for?

The Web Crypto API is widely supported — crypto.getRandomValues() works in every modern browser including Chrome, Firefox, Safari, and Edge, with support going back to 2015. But there are edge cases worth knowing about.

Non-secure contexts

Here's a gotcha that bit me: window.crypto.subtle (which you need for SHA-256 hashing in the commit-reveal scheme) is only available in secure contexts — meaning HTTPS or localhost. If your lottery page runs over plain HTTP, crypto.subtle will be undefined.

crypto.getRandomValues() itself works in non-secure contexts, but if you're building a full commit-reveal flow, you need HTTPS. Period.

Internet Explorer

If you need IE support (please don't), you'd use window.msCrypto.getRandomValues() as a fallback. But realistically, IE is dead. Focus on modern browsers and provide a clear "please update your browser" message for outliers.

Performance considerations

crypto.getRandomValues() is slower than Math.random() — roughly 10–50x slower per call depending on the browser and hardware. For our 5-winner draw from 412 tickets, this is irrelevant. The rejection sampling loop runs maybe 1-2 iterations on average, and the whole draw completes in under a millisecond.

But if you're generating millions of random values (say, shuffling a massive dataset), the performance gap matters. For lottery draws specifically? You'll never notice it.

How do you handle the entropy quality of client-side randomness?

A valid concern: browser-based CSPRNGs rely on the OS entropy pool, and historically, some systems (especially virtual machines and embedded devices) had low-entropy problems during early boot. Could a participant claim the draw was compromised because the system's entropy pool was depleted?

In practice, modern operating systems are extremely aggressive about entropy collection. Linux's CRNG (since kernel 4.8) blocks /dev/urandom reads until it has collected at least 128 bits of entropy at boot, after which it never blocks again. macOS and Windows have similar safeguards.

For a lottery draw happening in a user's browser — hours or days after system boot — entropy depletion is a non-issue. The OS has collected megabytes of environmental noise by that point.

If you want to be extra cautious, you can add a client-side entropy check:

async function verifyEntropyAvailable() {
  // Generate a 256-bit value and check it's not all zeros
  // (an extremely unlikely but theoretically possible failure mode)
  const buf = new Uint8Array(32);
  crypto.getRandomValues(buf);
  const allZero = buf.every(b => b === 0);
  if (allZero) {
    throw new Error('Entropy source may be compromised');
  }
  return true;
}

Is this overkill? Probably. But when fairness is the entire point of your application, a little paranoia is healthy.

What did we learn from rebuilding our raffle system?

After implementing crypto.getRandomValues() with rejection sampling and a commit-reveal scheme, we re-ran the draw and published the full verification package: the seed, the commitment hash, the draw algorithm, and a standalone HTML page where participants could paste the seed and reproduce the exact same winners.

Marcus verified it himself. He replied with a one-line email: "Okay, that's actually legit. Thanks for taking this seriously."

That exchange taught me something I now apply to every random number generation project: security and fairness aren't just about using the right function — they're about making the process transparent enough that skeptical participants can verify the claim themselves.

If you're building a lottery, raffle, or any selection tool where fairness matters, skip Math.random() entirely. Use the Web Crypto API. Add rejection sampling for uniform distribution. And if trust is on the line, implement a commit-reveal scheme so participants don't have to take your word for it.

The code is straightforward. The concepts are accessible. And the difference between "probably fair" and "provably fair" is exactly the difference between a draw people question and a draw people trust.

Frequently Asked Questions

Why is JavaScript Math.random() not secure for lottery draws?

JavaScript's Math.random() uses a pseudo-random number generator (PRNG) that is predictable and not cryptographically secure. Because it can be reverse-engineered, relying on it for a lottery draw opens the door to manipulation and unfair results. For any gambling or high-stakes application, it must be replaced with a secure alternative.

How do I use crypto.getRandomValues() instead of Math.random()?

You can use the Web Crypto API by calling window.crypto.getRandomValues() to generate truly unpredictable numbers. Unlike Math.random(), this method uses the operating system's secure random source. Simply pass a typed array, like new Uint32Array(1), to the function to receive a cryptographically secure random value.

What is the difference between Math.random() and crypto.getRandomValues()?

Math.random() is a fast PRNG designed for basic UI effects and simulations, lacking cryptographic strength. In contrast, crypto.getRandomValues() is designed to withstand cryptographic attacks and provides entropy from the OS level. This makes the latter mandatory for security-sensitive applications like raffles and lotteries.

Is client-side RNG secure enough for online lotteries?

While client-side RNG using the Web Crypto API generates highly secure random numbers, true fairness in online lotteries usually requires a server-side component. Relying solely on the client means users could theoretically alter the JavaScript code in their browser. For maximum trust, combine secure client-side generation with a provably fair server-seed mechanism.

How do I generate a random number between a specific range securely in JavaScript?

To avoid modulo bias when converting a secure random integer into a specific range, you should use an unbiased rounding technique. Generate a large random number using crypto.getRandomValues() and apply a mathematical formula that rejects values outside the unbiased range. This ensures every number in your lottery draw has an exactly equal probability of being chosen.

How can I prove a JavaScript lottery draw is fair?

To prove fairness, implement a provably fair system that uses cryptographic hashes of server seeds and client seeds before the draw takes place. After the draw, reveal the seeds so users can independently verify that the outcome was calculated honestly. This transparency proves the secure RNG wasn't tampered with during the lottery.

Can a user manipulate client-side random number generation?

Yes, if a lottery relies entirely on client-side JavaScript, a savvy user could open the browser console and override the RNG functions before the draw executes. Even if you use crypto.getRandomValues(), the environment itself is under the user's control. This is why critical lottery logic should always be validated or mirrored on a secure backend server.

Does crypto.getRandomValues() work in all modern browsers?

Yes, the Web Crypto API is widely supported across all modern web browsers, including Chrome, Firefox, Safari, and Edge. It is also supported in Node.js via the crypto global object, making it highly versatile. You can safely use it as a drop-in replacement for Math.random() in almost any modern web project.

What is modulo bias and how does it affect a secure lottery draw?

Modulo bias occurs when you use the modulo operator (%) to scale a large random number down to a smaller range, causing some numbers to appear more frequently than others. In a lottery, this means certain tickets might have a higher chance of winning, destroying the integrity of the draw. You must use an unbiased algorithm to map the secure RNG output to your desired range.

Should I use a third-party random number API for my lottery?

Using a third-party API can provide true randomness based on atmospheric noise, but it introduces latency and reliance on an external service. For a client-side approach, the native Web Crypto API is faster and perfectly secure if implemented correctly. If you do use an API, ensure the results are cryptographically verified to prevent man-in-the-middle attacks.