Skip to main content

How provably fair dice verification works

Recompute any Lightning Faucet dice roll yourself: the exact HMAC-SHA256 derivation, the seed and nonce gotchas that break most verifiers, and code to check it.

A provably fair dice roll is one you can recompute yourself, from scratch, after the fact. On Lightning Faucet every dice roll comes from three published inputs and one hash. If you have the server seed, the client seed and the nonce, you can reproduce the exact number the roll landed on, and confirm that the payout you were quoted is the payout the engine actually applied.

Here is the whole thing in one block. This is the real derivation our dice engine runs, not a simplified version of it:

combined_hash = HMAC_SHA256( key = server_seed, message = client_seed + ":" + nonce )
base_roll     = int( combined_hash[first 8 hex chars], 16 )  mod 100000     -> 0 to 99,999
dice_roll     = base_roll mod 10000                          -> 0 to 9,999

Three lines. That is the entire outcome pipeline for dice. Everything else in this article is the detail that makes your recomputation actually match ours: where each input comes from, what our server quietly does to your client seed before storing it, why the nonce you expect is often not the nonce we used, and the two reductions that trip up almost every hand written verifier.

The short definition

Provably fair dice verification is the process of proving that a dice result was fixed before you bet, by re-deriving it from a server seed the operator committed to in advance, a client seed you chose, and a counter called a nonce. The commitment is a SHA-256 hash of the server seed, published before you play. The reveal is the server seed itself, published after you stop using it. If the revealed seed hashes to the commitment you were shown, the operator could not have swapped it mid-session.

That is the guarantee. It is a narrow guarantee, and it is worth stating what it does not cover, which we do further down.

The three inputs, exactly as our engine stores them

Server seed: committed as a hash, revealed only when you rotate

When you first play a casino game on Lightning Faucet, we generate 32 random bytes, store them as a 64 character hex string on your account, and immediately compute sha256(server_seed). Only that hash is visible to you. It travels back with every dice roll as server_seed_hash, alongside your client seed, your nonce and the resulting roll.

You get the raw server seed by rotating it. Rotation does four things in one transaction: it archives the old server seed, its hash, the client seed and the number of games played into your seed history, it hands you the old server seed in the response, it mints a fresh 32 byte seed with a new published hash, and it resets your nonce to zero.

There is a fifth thing rotation does that catches people out, and it is not obvious from any generic explainer: rotation also resets your client seed back to default. If you had set a custom client seed, set it again after rotating, or the next batch of rolls will be committed under default and your saved verification notes will not line up.

Client seed: yours, but sanitized before storage

You can set your client seed to anything you like. What we store is not necessarily what you typed. The setter strips every character that is not a to z, A to Z or 0 to 9, then truncates to 64 characters. If the result is empty, it becomes the literal string default.

So my-lucky-seed! is stored, and hashed, as myluckyseed. 2026-07-04 is stored as 20260704. An emoji only seed becomes default.

This single behaviour is responsible for more failed verifications than every other cause combined. People verify against the string they typed into the box, get a completely different hash, and conclude something is wrong. Always verify against the client_seed value returned in the roll response or shown in your seed history, never against what you typed.

Nonce: per account, shared across every casino game, and pre-increment

The nonce is a single counter on your account. It is not a per game counter. Slots, dice, roulette, scratchcard and blackjack all draw from the same seed_nonce, and each play advances it by one.

Two consequences that matter enormously when you sit down to verify:

  1. Your dice nonces are usually not consecutive. Roll dice at nonce 12, spin a slot, then roll dice again: that second dice roll is nonce 14, not 13. If you are verifying a session by counting your dice rolls, you will be off by however many other games you played in between.
  2. The nonce used is the value before the increment. Your very first game on a fresh seed uses nonce 0, not 1. After that game your stored counter reads 1, which is the nonce the next game will use.

Every dice roll response carries the exact nonce that was consumed, in the fairness block. Copy that number. Do not derive it.

The increment and the hash computation happen inside the same database transaction, under a row lock on your account. That is what stops two simultaneous rolls from sharing a nonce and therefore sharing an outcome, which is the classic race condition in naive commit and reveal implementations.

The derivation, step by step

Step 1: the combined hash

combined_hash = HMAC_SHA256(key = server_seed, message = client_seed + ":" + nonce)

Note the argument order. The server seed is the key. The message is the client seed, a single colon, and the nonce rendered as a decimal integer with no padding. A meaningful number of provably fair guides show the opposite arrangement, with the client seed as the HMAC key, and several crypto libraries take their arguments in an order that makes the swap easy to commit by accident. Swapped arguments produce a perfectly valid looking 64 character hash that has nothing to do with your roll.

Step 2: the base roll, 0 to 99,999

base_roll = int(combined_hash[first 8 hex chars], 16) mod 100000

We take the first 8 hex characters, which is 32 bits, giving an integer from 0 to 4,294,967,295. That stays comfortably inside a native integer on every platform, which is deliberate: it means a verifier written in JavaScript, where numbers above 2^53 quietly lose precision, produces bit for bit the same answer as our PHP engine. Reductions from a full 64 character hash are common elsewhere and are a frequent source of JavaScript verifiers that disagree with the operator on a small fraction of results for reasons nobody can reproduce.

This base roll is shared infrastructure. Every casino game on Lightning Faucet runs through this same 0 to 99,999 value before mapping it onto its own outcome space.

Step 3: the dice roll, 0 to 9,999

dice_roll = base_roll mod 10000

Dice has 10,000 outcomes, numbered 0 through 9,999. The base roll is reduced a second time to land on one.

Why the double reduction matters, and why dice gets a free pass

Most dice verification tutorials on the internet tell you to compute int(hash[:8], 16) mod 10000 in one step. For Lightning Faucet dice, that shortcut happens to be exactly correct, and it is worth understanding why, because the same shortcut is wrong on other games running on this identical engine.

100,000 is an exact multiple of 10,000. Reducing modulo 100,000 and then modulo 10,000 is mathematically identical to reducing modulo 10,000 once. No information about the final answer survives the first reduction that the second one needs. So a hand written dice verifier that collapses the two steps will agree with us on every single roll, forever.

Contrast that with roulette on the same engine. Roulette does go through the same base roll, and then reduces onto 37 pockets. 100,000 is not a multiple of 37 (it leaves a remainder of 26), so collapsing the two reductions into one gives a different pocket on the overwhelming majority of spins. A verifier built by analogy from a dice tutorial would conclude the wheel was rigged, when the only defect was the missing intermediate step.

That is the practical takeaway for anyone writing verification code against any provably fair site: do not restate the derivation, replicate it. Two reductions that happen to commute on one game will not commute on the next, and the failure mode is a false accusation rather than an obvious crash.

The one place uniformity is not perfect

We would rather tell you this than have you find it: the mapping from a 32 bit integer to 10,000 outcomes is not perfectly uniform, because 4,294,967,296 is not a multiple of 10,000. It leaves a remainder of 7,296, so results 0 through 7,295 each have one more preimage than results 7,296 through 9,999.

The size of that asymmetry is about 0.00017 percent of total probability mass, spread across 7,296 of the 10,000 outcomes. It is roughly a thousand times smaller than the rounding applied to the payout multiplier, it is invisible below several billion recorded rolls, and it is inherent to any modulo based mapping that does not use rejection sampling. We are documenting it because a verifier who reproduces our math should understand its properties, not discover an undisclosed one later.

Verify a roll yourself

1. Capture the roll

Every dice roll response includes a fairness block with server_seed_hash, client_seed, nonce and roll. Save all four verbatim. The client_seed there is the post-sanitization value, which is the one you need.

2. Reveal the server seed

Rotate your seed. The response hands you the old server_seed plus the server_seed_hash it was published under. First check sha256(server_seed) against that hash and against the hash you saw on the roll itself. If those three agree, the seed you are about to verify with is provably the seed that was live at the time you bet. Then reset your custom client seed, because rotation cleared it.

Your seed history keeps every revealed seed with its first nonce, last nonce and games played, so you can verify a batch of rolls long after the fact rather than one at a time.

3. Recompute

Python:

import hmac, hashlib

server_seed = "the 64 hex chars revealed when you rotated"
client_seed = "myluckyseed"   # the SANITIZED value from the roll response
nonce       = 41              # the exact nonce from the roll response

combined = hmac.new(
    server_seed.encode(),                # key     = server seed
    f"{client_seed}:{nonce}".encode(),   # message = client_seed + ":" + nonce
    hashlib.sha256,
).hexdigest()

base_roll = int(combined[0:8], 16) % 100000   # 0 to 99,999
dice_roll = base_roll % 10000                 # 0 to 9,999

print(combined, base_roll, dice_roll)

JavaScript:

const crypto = require('crypto');

const combined = crypto
  .createHmac('sha256', serverSeed)     // key     = server seed
  .update(`${clientSeed}:${nonce}`)     // message = client_seed + ":" + nonce
  .digest('hex');

const baseRoll = parseInt(combined.slice(0, 8), 16) % 100000;
const diceRoll = baseRoll % 10000;

dice_roll must equal the roll from the response. If it does, the number was fully determined by a seed we committed to before you chose your target, and neither side could have influenced it.

4. Cross check against our verifier

You do not have to trust your own implementation either. The Provably Fair panel on the site calls a verify_game action that runs the same code path as the live game, with game_type set to dice, and takes your server_seed, client_seed and nonce. Pass the optional target, direction and wager too and it returns the resolved bet as well: the roll, whether it won, the win chance percentage, the multiplier and the payout in sats.

Because it calls the identical service class the live roll used, there is no second implementation to drift out of sync with the first. If your code and the endpoint disagree, the bug is in your code, and the fields it returns will usually tell you where.

There is one more fingerprint you can check. Every dice game row stores a game_hash computed as sha256(combined_hash + ":dice:" + roll), plus the base roll and the final roll separately. Recomputing that hash confirms not only the roll but that the stored record binds to the same combined hash, so an altered row cannot pass unnoticed.

How the roll becomes a win or a loss

Verification of the number is only half the job. The other half is confirming the bet resolved correctly.

You pick a target from 1 to 9,998 and a direction, over or under. Resolution is strict on both sides:

  • Under wins when roll < target.
  • Over wins when roll > target.

Both comparisons are strict, which means a roll exactly equal to your target is a loss in either direction. Under a target of 5,000 you win on 0 through 4,999, which is 5,000 of the 10,000 outcomes. Over a target of 5,000 you win on 5,001 through 9,999, which is 4,999 outcomes. That asymmetry is real, it is priced into the multiplier the interface quotes you before you commit, and the verifier reproduces it from the same function the live game used.

Two more mechanical details that show up in verification:

  • The multiplier is floored to four decimal places, and the payout is floored to whole sats. Lightning Faucet is sat denominated end to end, with no sub-sat accounting, so a fractional payout always rounds down to the whole sat.
  • Dice enforces a maximum payout. Extremely long shot targets can produce a nominal payout above that ceiling, in which case the response returns payout_capped and the max_payout value that applied. If you are reconciling a very high multiplier win, check those two fields before assuming a mismatch.

What verification proves, and what it does not

Provable fairness answers exactly one question: was this outcome fixed before the bet, by a seed the operator committed to in advance and cannot retroactively change? On Lightning Faucet the answer is yes, and you can demonstrate it yourself with twelve lines of code.

It does not make a losing session into a winning one, and it does not change the odds of any individual bet. The win chance for your chosen target is displayed before you roll, verification recomputes it from the same function, and no amount of seed rotation moves it. Anyone selling seed rotation as a way to improve results is selling you nothing.

It also does not, by itself, prove the operator paid you. That is a separate property, and on Lightning Faucet it is a Lightning property: balances are in sats, deposits and withdrawals run over Lightning, and withdrawals use LNURL-withdraw, so you scan a QR code or tap a link and your wallet fetches the invoice automatically. Verifiable outcomes and instant settlement are two independent things you should expect together.

Common reasons a verification fails

In rough order of how often we see them:

  1. Verifying against the client seed you typed rather than the sanitized one. Punctuation, spaces and emoji are stripped before storage. Use the value from the response.
  2. Using the wrong nonce. The counter is shared across all casino games and is consumed pre-increment. Copy the nonce from the roll, do not count your dice rolls.
  3. HMAC arguments swapped. Server seed is the key, client_seed:nonce is the message.
  4. Comparing against a rotated seed. A revealed seed only verifies the rolls made while it was active. Check sha256(server_seed) against the server_seed_hash on the specific roll first.
  5. Forgetting the client seed reset. Rotation returns the client seed to default. Rolls made after rotating and before you set a custom seed again were committed under default.
  6. Precision loss. Reducing from more than 8 hex characters in a language with 53 bit floats will disagree with us intermittently. Take 32 bits.

Try it on a live roll

The fastest way to internalise any of this is to do it once on a real bet. Open dice, set a client seed you will remember, roll once at the minimum stake, then rotate your seed and recompute that roll with the Python snippet above. It takes about two minutes and it is the difference between believing a fairness claim and having checked one.

If you do not have a balance yet, the free spin and the other earn surfaces fund an account in sats without a deposit, and every game they fund is committed under the same seed model.

The same commit and reveal machinery runs underneath the rest of the casino, with each game applying its own mapping to the shared base roll. Multiplayer Lightning poker uses a variant built for a shared deck: the seed belongs to the table rather than to one player, the nonce advances once per hand, any seated player can set the table client seed, and revealing rotates the table seed so every hand played under it can be re-derived. And on prediction markets there is no seed at all, because outcomes resolve against real world events rather than a hash. Knowing which of those three models you are looking at is most of what it takes to evaluate any fairness claim you encounter.

Frequently asked questions

What is provably fair dice verification?

It is proving a dice result was fixed before you bet. Lightning Faucet publishes a SHA-256 commitment to a server seed before you play, and reveals the seed itself once you rotate it. With the revealed server seed, your client seed and the nonce, you recompute combined_hash = HMAC_SHA256(key = server seed, message = client_seed:nonce), take the first 8 hex characters as an integer, reduce modulo 100000 to get the base roll, then reduce modulo 10000 to get the dice roll from 0 to 9,999. If your number matches the roll you were shown, the outcome could not have been altered after you placed the bet.

How do I get the server seed for a roll I already made?

Rotate your seed. Rotation archives the current server seed to your seed history, returns it to you in plain text, and mints a fresh committed seed. Before you verify anything, check that sha256(the revealed server seed) matches the server_seed_hash that was attached to the roll. Note that rotation also resets your client seed to 'default' and your nonce to 0, so set your custom client seed again afterwards if you use one.

Why does my verification produce a different roll than the site showed?

Almost always one of three causes. First, the client seed: Lightning Faucet strips every non-alphanumeric character and truncates to 64 characters before storing it, so 'my-lucky-seed!' is stored as 'myluckyseed'. Verify against the client_seed returned in the roll response, not what you typed. Second, the nonce: it is a single counter shared across dice, slots, roulette, scratchcard and blackjack, and the value used is the one before the increment, so your first game ever uses nonce 0. Third, HMAC argument order: the server seed is the key and 'client_seed:nonce' is the message, and swapping them yields a valid looking but unrelated hash.

Can I collapse the two modulo steps into one when verifying dice?

For dice, yes, and it will always agree with us. 100,000 is an exact multiple of 10,000, so reducing modulo 100000 and then modulo 10000 gives the same answer as reducing modulo 10000 once. Do not carry that shortcut to other games on the same engine. Roulette also runs through the shared base roll before reducing onto its 37 pockets, and because 100,000 is not a multiple of 37 the collapsed version disagrees with the real pocket on most spins. Replicate each game's derivation rather than restating it.

Does a roll exactly equal to my target win?

No. Both comparisons are strict. 'Under' wins only when the roll is strictly less than your target, and 'over' wins only when the roll is strictly greater. So under a target of 5,000 you win on 0 through 4,999, which is 5,000 of 10,000 outcomes, while over a target of 5,000 you win on 5,001 through 9,999, which is 4,999. The win chance shown before you roll already reflects this, and the verifier recomputes it with the same function the live game uses.

Does changing my client seed improve my chances?

No. The client seed exists so that you contribute entropy the operator cannot predict when it commits to the server seed, which is what makes the commitment meaningful. It does not shift the odds of any target, and neither does rotating your seed. Anyone presenting seed rotation as a winning strategy is describing something the math does not support.

Is the mapping from hash to roll perfectly uniform?

Very nearly, and we would rather document the gap than have you find it. The first 8 hex characters give a 32 bit integer with 4,294,967,296 possible values, which is not an exact multiple of 10,000. The remainder is 7,296, so results 0 through 7,295 each have one extra preimage compared with results 7,296 through 9,999. That asymmetry is around 0.00017 percent of total probability mass, far below the rounding applied to the payout multiplier, and it is intrinsic to any modulo based mapping that does not use rejection sampling.

Where can I verify a roll without writing code?

The Provably Fair panel on Lightning Faucet exposes a verify_game action that runs the same service class as the live game. Give it game_type dice, your server seed, client seed and nonce, and optionally the target, direction and wager, and it returns the combined hash, the roll, whether the bet won, the win chance, the multiplier and the payout in sats. Because it is the same code path rather than a second implementation, it cannot drift out of sync with the game itself.