Provably Fair Games: How Casinos Can Prove They're Not Cheating
by Toshi, Lead Developer
When you roll dice at a physical casino, you can watch them leave your hand, bounce off the felt, and land. The physics is right there. Nobody secretly swapped your six for a one.
Online, that guarantee evaporates. You click a button, a number appears, and you have no way to know whether the server generating it was honest. Provably fair is the cryptographic answer to that problem — a technique that lets a casino prove, mathematically and after the fact, that every outcome was determined before your bet was placed and that nobody could have changed it.
Here's exactly how it works.
The Sealed Envelope
The underlying concept is a cryptographic commitment scheme, and it's easier to understand with an analogy.
Imagine you want to predict a coin flip before it happens without revealing your answer until after. You write "heads" on a piece of paper, seal it in an envelope, and hand it to someone else. After the flip, you open the envelope. The seal proves you couldn't have changed your answer after seeing the result.
Provably fair gambling works the same way, but with cryptographic hash functions instead of envelopes. The casino commits to a secret value by publishing its hash. You can verify the commitment after the fact. The casino can't change its answer retroactively. That's the whole trick — everything else is implementation detail.
The Three Inputs
Every outcome is derived from three values:
- Server seed — A random string generated by the casino's server. Players never see it directly; they see its SHA-256 hash upfront. When the session ends and seeds rotate, the casino reveals the actual value. You hash it yourself and check it matches. If it does, the casino was locked in from the start.
- Client seed — Provided by you. You can set it to anything. Its purpose is to inject randomness the server doesn't control — even if the casino's seed generation were somehow predictable, your client seed mixes in entropy the server couldn't have anticipated.
- Nonce — A counter that starts at zero and increments by one after every single bet. Without it, every bet on the same session would produce the same outcome. The nonce makes each one unique.
Combined: outcome = f(serverSeed, clientSeed, nonce). No randomness is generated at bet time. The outcome was already determined the moment the server seed was committed to.
HMAC-SHA256: Why It Matters
Early provably fair implementations simply hashed all three inputs together:
SHA-256(serverSeed + clientSeed + nonce)
This has a structural weakness: plain SHA-256 is vulnerable to length-extension attacks, where an attacker who knows the hash output can extend the message without knowing the secret. The modern standard is HMAC-SHA256:
HMAC-SHA256(key = serverSeed, message = "clientSeed:nonce:game")
HMAC uses the server seed as a cryptographic key, not just concatenated input. This eliminates the length-extension vulnerability and makes the construction cleaner to reason about from a security standpoint.
Notice the game label at the end. Every game type — dice, crash, mines — includes its own unique label in the message. The same seed pair produces completely independent outcomes in different games. Without labels, outcomes across games would be correlated.
The RNG Pipeline
HMAC-SHA256 gives us 32 bytes of output. We need to turn that into a usable game outcome.
First, split those 32 bytes into eight 32-bit unsigned integers, reading left to right:
function* getUint32Stream(serverSeed: string, message: string) {
let step = 0;
while (true) {
const hmac = createHmac('sha256', serverSeed);
hmac.update(`${message}:${step}`);
const hash = hmac.digest();
for (let i = 0; i < 32; i += 4) {
yield ((hash[i] << 24) | (hash[i+1] << 16) | (hash[i+2] << 8) | hash[i+3]) >>> 0;
}
step++; // extend the stream if more values are needed
}
}
The step counter means the stream extends indefinitely — games like Mines that need to shuffle a 25-tile board simply consume more values.
Modulo Bias: The Trap Almost Everyone Falls Into
Each uint32 is a number between 0 and 4,294,967,295. For a dice game with 10,000 possible outcomes, the obvious move is:
rollValue = uint32 % 10000
This is subtly broken. Here's why.
2³² = 4,294,967,296. That doesn't divide evenly by 10,000 — there's a remainder of 7,296. This means outcomes 0–7,295 can be produced by 429,497 different uint32 values, while outcomes 7,296–9,999 can only be produced by 429,496. The first group is fractionally more likely. The bias is tiny — about 0.00023% — but it's real, measurable over millions of bets, and fundamentally dishonest.
The fix is rejection sampling: define a cutoff and throw away values that fall in the biased zone.
function getUnbiasedInt(stream: Generator<number>, range: number): number {
const maxAcceptable = 0x100000000 - (0x100000000 % range);
for (const value of stream) {
if (value < maxAcceptable) return value % range;
// discard and draw the next value from the stream
}
}
For range = 10,000, you'll discard a value roughly once every 600,000 draws. Almost never. But you must always implement the check — and the bias gets meaningfully worse at larger ranges, hitting ~0.007% at one million possible outcomes.
The Fisher-Yates Shuffle
Games like Mines and Keno need to shuffle an entire array — place 5 mines across 25 tiles, or draw 10 winning numbers from 40. The correct algorithm is Fisher-Yates:
function shuffle<T>(array: T[], rng: () => number): T[] {
const result = [...array];
for (let i = result.length - 1; i > 0; i--) {
const j = Math.floor(rng() * (i + 1));
[result[i], result[j]] = [result[j], result[i]];
}
return result;
}
Where rng() is derived from the provably fair stream — never Math.random(). The common alternative, array.sort(() => Math.random() - 0.5), produces biased results because comparison-based sorting doesn't call the comparator a fixed number of times. Always use Fisher-Yates.
Seed Rotation: The Verification Moment
You can't use the same server seed forever. Rotation is how the casino proves its honesty.
When you rotate, the server reveals the actual server seed. You compute its SHA-256 yourself and check it matches the hash you were shown at the start of the session. If it matches, the casino couldn't have changed it after committing. The nonce resets to zero, a new server seed is generated, and a new session begins.
That matching hash check is the whole proof. It's one line of code, and it settles the question permanently.
The Grinding Attack — and Why Timing Is Everything
Here's where most explanations stop short. HMAC is better than plain SHA-256, but it doesn't by itself prevent a cheating server.
If the casino generates the server seed after seeing your client seed, it can grind through candidates until it finds one that makes you lose. The hash will verify correctly. The commitment will look legitimate. You'll never know.
What prevents this is commitment timing, not the hash function.
The server must publish SHA-256(serverSeed) before it has any knowledge of your client seed. Lock-in first, input second. If those two steps are ever swapped, the scheme is broken regardless of what cryptography sits underneath.
But there's a subtler version of this attack. What if the casino pre-generates millions of (serverSeed, hash) pairs, then — after you rotate and set a new client seed — picks from its pool the pair that produces the worst outcome for you? The commitment came before the bets, but the casino chose which commitment to show you after seeing your client seed.
The fix: pre-commit the next seed
A correctly implemented casino always shows two hashes: the active session's hash, and a next server hash — committed while the current session is still running, before you've decided to rotate, before any new client seed exists.
Current session active
├─ Next Server Hash already visible ← committed before rotation
│
Player rotates
├─ Current serverSeed revealed and verified
├─ Next Server Hash becomes the new active hash
├─ A brand new Next Server Hash generated and published immediately
The next session's seed is chosen before the server has any knowledge of what the next client seed will be. The grinding attack has no window to operate in.
That "Next Server Hash" field in the seeds panel isn't cosmetic. It's load-bearing security.
What You Should Always Verify
After every session rotation:
- Take the revealed server seed
- Compute
SHA-256(revealed)— any online tool or one line of code - Compare it to the hashed server seed you saw before betting
- Match → that session was fair. No match → something is wrong.
For any individual bet, the server seed (post-rotation), your client seed, and the nonce from your bet history are all you need to recompute the exact outcome independently. A trustworthy casino publishes the algorithm openly and provides a verification tool — but you should never need the tool. The algorithm is simple enough to run yourself.
One Last Thing: Provably Fair ≠ Good Odds
Provably fair proves the dice aren't loaded. It says nothing about the payout structure.
A casino builds its edge into multipliers — paying slightly less than true odds on every win. A genuine 50/50 bet paying 2× has zero house edge. One paying 1.98× has a 1% edge baked in. Over millions of bets, that difference flows to the casino.
This is normal, expected, and completely separate from fairness. What provably fair guarantees is that the random outcomes are genuinely random — not manipulated based on your bet size, your losses, or anything else. Combined with transparent payout maths, players have everything they need to make an informed decision before they play.
The casino's honesty becomes provable, not just claimed. That's the point.