Provably Fair RNG: 14-Step Hash-Chain, 45 Min [2026]

Provably-fair RNG is the one piece of casino infrastructure a player can audit without trusting you. Every bet ships with a cryptographic receipt: a pre-published SHA-256 commitment, a client seed the player controls, an incrementing nonce, and – once the round is settled – a revealed server seed that hashes back to exactly what you promised. Re-run the math yourself and the outcome either checks out or it does not. There is no middle ground, no “trust our licence,” no black-box RNG certificate to take on faith.

This tutorial wires up a production-grade provably-fair engine in Node.js 24 LTS with TypeScript 6, using the exact HMAC-SHA256 byte-generator that Stake, Stake.us and BC.Game deploy in production – plus a layer most guides skip entirely: a pre-committed reverse hash chain that locks your entire future sequence of server seeds at launch. By the end you will have a working dice, crash, roulette, and Fisher-Yates shuffle pipeline, a Postgres ledger, a rotation lifecycle, and a standalone verifier any player can run offline. Budget about 45 minutes. Every code block below runs as written.

Google · Preferred Sources

Don't miss new tech stories on Google

Add Tech Insider once in the Google app and our stories appear in your news suggestions.

Add Now

Why Provably-Fair RNG Still Wins Player Trust in 2026

The crypto-casino market stopped being a niche years ago. In Q1 2025 alone, total wagers at crypto casinos hit roughly $26 billion, nearly double the same quarter a year earlier, and crypto-native operators now account for close to 17% of all iGaming bets globally – up from effectively nothing five years back. Stake alone reports around $4.7 billion in gross gaming revenue, an estimated $10 billion in monthly wagers, and about 127 million monthly visits under its Curaçao licence – and as of April 2026 Stake has rolled provably-fair mechanics out across more than 1,200 games on its platform, far beyond the handful of dice and crash originals most write-ups still describe. Analysts peg the broader crypto-gambling market anywhere from $6.6 billion in 2025 scaling to $17.1 billion by 2032 at a 15.1% CAGR, with the more bullish desks projecting north of $65 billion in annual turnover by the end of 2026.

What every one of those operators shares is a transparency mechanism that a traditional RNG certificate cannot match. A GLI-19 or eCOGRA seal tells a player that an auditor checked the RNG at a point in time. Provably-fair tells the player that they can check this specific bet right now. That shift – from institutional trust to mathematical verification – is why the model keeps spreading beyond crypto into fiat-settled originals.

There is a hard commercial case underneath the ideology, too. A verifiable outcome is a dispute that never reaches support: when a player can re-derive their own loss, the “the game is rigged” ticket evaporates before it is filed. Operators report that the fairness widget doubles as a retention and acquisition tool – it is a marketing surface that competitors relying on a closed RNG simply cannot copy. The 2025-2026 industry narrative even has a name for the next stage, “Provably Fair 2.0,” in which commitments migrate on-chain through smart contracts so the operator cannot quietly discard an inconvenient seed history. Whether or not you go on-chain, the baseline expectation among crypto-native players in 2026 is that every original game exposes server seed, client seed, nonce and a matching hash. Shipping without it now reads as a red flag rather than a neutral default.

The trust model in one sentence

Commit before, reveal after: the operator publishes SHA-256(serverSeed) before the player bets, so the seed is frozen; after the round, the operator reveals the seed, the player hashes it, and if the hash matches the commitment the operator provably could not have changed the outcome mid-round. SHA-256’s second-preimage resistance is the entire guarantee – finding a different seed that produces the same commitment is computationally infeasible, which is why the algorithm is standardised in NIST FIPS 180-4.

Where this tutorial goes further than the reference designs

Most published walkthroughs stop at a single commit-reveal pair per seed. We add a reverse hash chain so that not just the active seed but the entire future rotation sequence is committed at genesis, we eliminate modulo bias the way the production engines actually do it, we make the nonce counter safe under concurrency, and we ship an independent verifier. If you want the strategic view of how this stacks up against paid certification, our breakdown of provably fair vs independently audited RNG covers the cost and compliance trade-offs in depth.

Commit-Reveal vs Hash-Chain: Two Architectures, One Guarantee

There are two families of provably-fair architecture in production, and conflating them is the most common design mistake. Both use SHA-256; they differ in what is committed and when.

Model A – Per-user commit-reveal with nonce

This is the Stake / BC.Game model. Each player has one active server seed. Its commitment is SHA-256(serverSeed), published the moment the seed becomes active. Every bet reuses the same seed but increments a per-seed nonce, so bet #1 and bet #2 draw from different HMAC outputs. When the player rotates, the old seed is revealed and a fresh seed’s hash is committed. It is simple, stateless per bet, and scales horizontally because each player’s chain is independent.

Model B – Pre-committed reverse hash chain

Popularised by Bustabit and used by crash-heavy operators such as Gamdom, this model pre-generates a chain of seeds where each seed is the SHA-256 of the next: seed[i] = SHA-256(seed[i+1]). The very first hash, seed[0], is published permanently before any round is played. Because the whole chain is determined by a single secret tail seed, the operator has cryptographically committed to every future outcome at launch – they cannot alter round 5,000 without breaking the hash link all the way back to the public genesis. The trade-off is that the sequence is global and fixed; you cannot let a player choose their own client seed unless you layer HMAC on top.

Concretely: suppose you build a 10-million-link chain today and publish seed[0] = 3f9a…c1. Six months and two million rounds later, a sceptical player takes the seed you revealed for round 2,000,000, hashes it, hashes that result, and keeps going – 2,000,000 SHA-256 operations, a few seconds on a laptop – and lands exactly on the 3f9a…c1 you posted before the site even launched. There is no point in that chain where you could have inserted a favourable outcome, because doing so would have changed seed[0], which is immutable and public. That property – retroactive, walk-the-whole-history verifiability from a single anchor – is what the hash chain buys you over per-seed commit-reveal, and it is precisely the guarantee generic explainers gloss over.

The hybrid this tutorial ships

We combine both: a reverse hash chain governs the sequence of server seeds a user rotates through (so even your rotation future is pre-committed), while an HMAC-SHA256 byte generator with client seed, nonce and cursor governs each bet within a seed. You get the player-controllable randomness of Model A and the launch-time commitment of Model B.

Property Model A: Commit-Reveal Model B: Hash Chain Hybrid (this guide)
Committed at launch Active seed only Entire future sequence Entire rotation sequence + per-bet HMAC
Player-set client seed Yes No (global) Yes
Per-bet uniqueness Nonce Chain position Nonce + cursor within seed
Verification anchor Prior commitment Public genesis hash Public genesis + prior seed
Used by Stake, BC.Game Bustabit, Gamdom –

Prerequisites: Stack, Dependencies, and Versions

Everything here targets the current LTS line. Node.js 24 entered Long-Term Support as “Krypton” with the 24.11.0 release and is maintained through April 2028; the latest patch at the time of writing is v24.18.0 (23 June 2026). Node 24 matters here for two reasons: the crypto module ships hardened root certificates and, since 24.16.0, native randomUUIDv7() for time-sortable identifiers you can use as idempotency keys. Pin your toolchain to the exact versions below to reproduce the output shown later.

Dependency Version Role
Node.js 24.18.0 LTS “Krypton” Runtime, native node:crypto
TypeScript 6.0.3 Type safety, generics
Express 5.2.1 HTTP API (commit / bet / reveal)
pg (node-postgres) 8.22.0 Ledger + chain persistence
zod 4.4.3 Request validation
tsx 4.22.5 Run TS without a build step
vitest 4.1.9 Determinism tests
PostgreSQL 17.x Durable audit store

Scaffold the project

mkdir provably-fair && cd provably-fair
npm init -y
npm pkg set type=module
npm i [email protected] [email protected] [email protected]
npm i -D [email protected] [email protected] [email protected] @types/express @types/node @types/pg
npx tsc --init --module nodenext --target es2024 --strict
node --version   # expect v24.x

Directory layout

src/
  crypto/
    hash.ts        # sha256 helpers
    chain.ts       # reverse hash chain builder
    rng.ts         # HMAC byte generator + floats
  games/
    dice.ts crash.ts roulette.ts shuffle.ts
  db/
    schema.sql pool.ts
  api/
    server.ts routes.ts
  verify.ts        # standalone offline verifier

Because everything derives from node:crypto, there are no native add-ons to compile and no supply-chain surface beyond Express, pg and zod – a deliberate choice for a component that has to be auditable.

Step 1–2: Generate the Server-Seed Hash Chain

The foundation is a cryptographically secure secret. Never use Math.random() – it is a non-cryptographic PRNG and its state is recoverable. Use crypto.randomBytes(32), which draws from the OS CSPRNG and gives you a 32-byte (64-hex-character) seed, exactly the width Stake and BC.Game use – comfortably inside the 32–64 byte range ToolsGambling’s April 2026 server-seed guidance recommends, and well clear of the 128-bit entropy floor Tech Insider’s June 2026 RNG-seed standard sets, since 32 bytes already carries 256 bits of entropy, double what’s required.

SHA-256 helpers

// src/crypto/hash.ts
import { createHash } from 'node:crypto';

export const sha256 = (input: string): string =>
  createHash('sha256').update(input, 'utf8').digest('hex');

// Timing-safe comparison for commitments (avoid == on secrets)
import { timingSafeEqual } from 'node:crypto';
export function safeEqualHex(a: string, b: string): boolean {
  if (a.length !== b.length) return false;
  return timingSafeEqual(Buffer.from(a, 'hex'), Buffer.from(b, 'hex'));
}

Build the reverse chain

Pick a random secret tail seed, then hash backwards. Each earlier element is the SHA-256 of the one after it, so revealing any seed lets the player verify it against the previously known value.

// src/crypto/chain.ts
import { randomBytes } from 'node:crypto';
import { sha256 } from './hash.js';

/**
 * Returns a chain of length+1 hex strings where:
 *   chain[0]              -> PUBLIC genesis, published before any play
 *   chain[1..length]      -> server seeds, handed out in order
 *   sha256(chain[i]) === chain[i-1]   for every i >= 1
 */
export function buildReverseChain(length: number): string[] {
  const chain = new Array<string>(length + 1);
  chain[length] = randomBytes(32).toString('hex'); // secret tail
  for (let i = length - 1; i >= 0; i -= 1) {
    chain[i] = sha256(chain[i + 1]);
  }
  return chain;
}

Expected output for a 3-seed chain (values differ every run because the tail is random):

> const c = buildReverseChain(3);
> c[0]  // genesis, PUBLISHED
'a1f0...c9'  // === sha256(c[1])
> sha256(c[1]) === c[0]   // true
> sha256(c[2]) === c[1]   // true

Store the chain server-side with only chain[0] ever exposed until a seed is legitimately revealed on rotation. In practice you would generate a few thousand seeds per user so a rotation never runs the chain dry mid-session.

Step 3–4: Publish the Commitment (the Public Hash)

The commitment is the load-bearing promise. For the hybrid design, the commitment for the currently active seed chain[i] is simply chain[i-1] – a value the player already holds, because it was either the public genesis (for the first seed) or the last seed you revealed. You still expose a convenience endpoint so front-ends can fetch the current commitment and genesis without reconstructing state.

Postgres schema

-- src/db/schema.sql
CREATE TABLE fairness_state (
  user_id      BIGINT PRIMARY KEY,
  genesis_hash TEXT   NOT NULL,        -- public chain[0], permanent
  active_index INT    NOT NULL,        -- which chain position is live
  client_seed  TEXT   NOT NULL,
  nonce        BIGINT NOT NULL DEFAULT 0
);

CREATE TABLE fairness_seed (
  user_id     BIGINT NOT NULL,
  idx         INT    NOT NULL,         -- chain position (1..N)
  server_seed TEXT   NOT NULL,         -- secret until revealed
  commitment  TEXT   NOT NULL,         -- sha256(server_seed) == prior seed
  revealed_at TIMESTAMPTZ,
  PRIMARY KEY (user_id, idx)
);

CREATE TABLE bet_ledger (
  id          BIGSERIAL PRIMARY KEY,
  user_id     BIGINT NOT NULL,
  idx         INT    NOT NULL,
  nonce       BIGINT NOT NULL,
  game        TEXT   NOT NULL,
  client_seed TEXT   NOT NULL,
  commitment  TEXT   NOT NULL,
  outcome     JSONB  NOT NULL,
  created_at  TIMESTAMPTZ NOT NULL DEFAULT now(),
  UNIQUE (user_id, idx, nonce)         -- no nonce is ever spent twice
);

The commitment endpoint

// src/api/routes.ts (excerpt)
router.get('/fairness/commit', async (req, res) => {
  const userId = req.user.id;
  const { rows } = await pool.query(
    `SELECT s.commitment, st.genesis_hash, st.client_seed, st.nonce, st.active_index
       FROM fairness_state st
       JOIN fairness_seed  s ON s.user_id = st.user_id AND s.idx = st.active_index
      WHERE st.user_id = $1`, [userId]);
  res.json(rows[0]); // commitment, genesis_hash, client_seed, nonce, active_index
});

The client seed defaults to a random value the player can overwrite. Letting the player set it is what makes the outcome jointly unpredictable: even a malicious operator cannot pick a server seed that produces a favourable result for a specific client seed they do not control in advance.

Two client-seed rules trip people up. First, freeze the client seed for the life of a server seed – a player should only be able to change it at rotation, otherwise the pairing between commitment and outcome becomes ambiguous and verification gets messy. Second, never silently normalise or trim the client seed string on the server: if the player sends "Alice's seed " with a trailing space, hash and HMAC exactly that, byte for byte, because their verifier will. The most common “verifier says I lost but the site says I won” support ticket is not fraud – it is a whitespace or Unicode-normalisation mismatch between what the browser sent and what the server hashed. Validate with zod for length and character set, reject anything suspicious outright, but do not mutate what you accept.

Step 5–6: The Canonical Byte Generator (HMAC-SHA256 + Cursor)

This is the heart of the engine and the part every generic guide gets vague about. The production standard – published openly in Stake’s implementation reference and mirrored byte-for-byte by BC.Game and every Stake-engine casino – feeds four inputs into HMAC-SHA256: serverSeed as the key, and the string clientSeed:nonce:round as the message, where round lets you stream more than 32 bytes.

Implementing the byte generator

// src/crypto/rng.ts
import { createHmac } from 'node:crypto';

export function* byteGenerator(
  serverSeed: string,
  clientSeed: string,
  nonce: number,
  cursor = 0,
): Generator<number, never> {
  let round = Math.floor(cursor / 32);
  let roundCursor = cursor % 32;
  while (true) {
    const hmac = createHmac('sha256', serverSeed);
    hmac.update(`${clientSeed}:${nonce}:${round}`);
    const buffer = hmac.digest();            // 32 bytes
    while (roundCursor < 32) {
      yield buffer[roundCursor];             // one byte 0..255
      roundCursor += 1;
    }
    roundCursor = 0;
    round += 1;                              // next HMAC block
  }
}

Why the cursor matters

A single HMAC-SHA256 digest is 32 bytes. A dice roll needs 4; a 52-card shuffle needs over 200. The cursor tracks how deep into the byte stream you are: round = floor(cursor / 32) selects which HMAC block, and cursor % 32 selects the byte within it. Because round is baked into the HMAC message, block 0 and block 1 are cryptographically independent – you are not just reading further into one hash, you are computing fresh HMACs keyed identically but with an incrementing round counter. This is what lets one (serverSeed, clientSeed, nonce) triple produce an unbounded, deterministic, verifiable byte stream. HMAC itself is standardised in RFC 2104, and Node’s implementation wraps OpenSSL, so a verifier in any language reproduces the same bytes. For broader market context, see our coverage of best sweepstakes casino sites.

Step 7–8: Bytes → Uniform Floats Without Modulo Bias

Here is where naïve implementations silently break fairness. If you take a byte and do byte % 6 to roll a die, you have introduced modulo bias: 256 is not divisible by 6, so results 0–3 are ever so slightly more likely than 4–5. On millions of bets, that skew is real money. The production engines avoid it entirely by never using modulo on raw bytes.

The float extraction method

Take four consecutive bytes and treat them as a base-256 fraction: byte0/256 + byte1/256² + byte2/256³ + byte3/256⁴. The result is a uniform double in [0, 1) with 32 bits of resolution. Multiply by your range and floor. Because the mapping from a 32-bit value to [0,1) is exact and the subsequent multiply distributes the tiny rounding uniformly, there is no meaningful bias for any range under a few million.

// src/crypto/rng.ts (continued)
export function floats(
  serverSeed: string, clientSeed: string,
  nonce: number, count: number, cursor = 0,
): number[] {
  const rng = byteGenerator(serverSeed, clientSeed, nonce, cursor);
  const out: number[] = [];
  for (let i = 0; i < count; i += 1) {
    let value = 0;
    for (let j = 0; j < 4; j += 1) {
      value += rng.next().value / 256 ** (j + 1);
    }
    out.push(value);                 // uniform in [0, 1)
  }
  return out;
}

Rejection sampling: the alternative

If you must map to a range where absolute uniformity is a compliance requirement (say a 1-in-N lottery draw audited under GLI-19), rejection sampling is the textbook fix: draw bytes, discard any draw that falls in the biased tail above floor(MAX/N)·N, and retry. It is slower and non-constant-time, which is why real-time games prefer the float method – but it is worth knowing both, because the choice is exactly the kind of nuance that separates a certified integration from a hobby project. Operators evaluating certified paths alongside self-hosted fairness often run the numbers against white-label casino platforms before deciding how much of the stack to own.

Step 9–10: Mapping Floats to Game Outcomes

With a uniform float in hand, each game is a one-liner. The formulas below match the outcomes real players verify on production engines. The house edge is applied in the payout table, not by skewing the RNG – a critical separation that keeps the randomness itself provably uniform, and the same separation regulators look for: a June 2026 provably-fair casino certification reviewed by Provably Fair covered ten titles – Plinko, Dice, Mines and seven more – each judged on exactly this kind of outcome-mapping table.

Dice (0.00–100.00)

// src/games/dice.ts
import { floats } from '../crypto/rng.js';

export function dice(serverSeed: string, clientSeed: string, nonce: number): number {
  const [f] = floats(serverSeed, clientSeed, nonce, 1);
  return Math.floor(f * 10001) / 100;   // 0.00 .. 100.00, 10,001 outcomes
}

Crash / Limbo multiplier

// src/games/crash.ts
export function crashPoint(
  serverSeed: string, clientSeed: string, nonce: number, houseEdge = 0.01,
): number {
  const [f] = floats(serverSeed, clientSeed, nonce, 1);
  if (f === 0) return 1;
  const raw = (1 - houseEdge) / (1 - f);         // heavy right tail
  return Math.max(1, Math.floor(raw * 100) / 100);
}

The instant-bust probability equals the house edge, and the multiplier grows without bound as f approaches 1 – the classic crash distribution. Cap it (e.g. at 1,000,000×) in the payout layer, never in the RNG.

Roulette (single zero)

// src/games/roulette.ts
export function roulette(serverSeed: string, clientSeed: string, nonce: number): number {
  const [f] = floats(serverSeed, clientSeed, nonce, 1);
  return Math.floor(f * 37);            // 0..36, European wheel
}
Game Floats used Mapping Range
Dice 1 floor(f·10001)/100 0.00–100.00
Roulette 1 floor(f·37) 0–36
Crash 1 (1−edge)/(1−f) 1.00×–cap
Plinko (16 rows) 16 sum of f<0.5?0:1 0–16 bucket
Mines (5×5) 24 shuffle, first k = mines positions

Step 11: Deterministic Fisher-Yates Shuffle for Cards & Mines

Card games, mines and any “reveal a board” mechanic need a shuffle, not a scalar. This is the single most-skipped topic in provably-fair guides – and the easiest to get subtly wrong. A biased shuffle is a cheating shuffle. The only correct approach is Fisher-Yates driven by your uniform floats.

The shuffle algorithm

// src/games/shuffle.ts
import { floats } from '../crypto/rng.js';

export function shuffle<T>(
  items: readonly T[], serverSeed: string, clientSeed: string, nonce: number,
): T[] {
  const deck = [...items];
  const n = deck.length;
  const rand = floats(serverSeed, clientSeed, nonce, n - 1); // n-1 swaps
  for (let i = n - 1, k = 0; i > 0; i -= 1, k += 1) {
    const j = Math.floor(rand[k] * (i + 1));   // uniform 0..i, no modulo bias
    [deck[i], deck[j]] = [deck[j], deck[i]];
  }
  return deck;
}

Building a 52-card deck and a mines board

const RANKS = [...'23456789TJQKA'];
const SUITS = [...'shdc'];
const DECK = SUITS.flatMap((s) => RANKS.map((r) => r + s)); // 52 cards

export const dealCards = (ss: string, cs: string, n: number) =>
  shuffle(DECK, ss, cs, n);

export function mines(ss: string, cs: string, n: number, count = 3): number[] {
  const board = shuffle([...Array(25).keys()], ss, cs, n);
  return board.slice(0, count).sort((a, b) => a - b);  // mine positions
}

The key correctness point: iterate i downward and pick j in [0, i] using a fresh uniform float per swap. Never reuse a float and never use modulo. A player can re-run this exact function with the revealed seed and reproduce the identical deck – that is the whole promise. Because the float mapping is bias-free, every one of the 52! orderings is equally likely.

Step 12: The Reveal + Rotation Lifecycle

A commitment is worthless without a reveal. Rotation is what lets a player “cash out” their trust: they ask for a new seed, you reveal the old one, and they verify every bet they placed against it. Rotation also limits exposure – even in the theoretical event a seed leaked, only bets under that one seed are affected.

Reveal endpoint with hash-chain proof

// src/api/routes.ts (excerpt)
router.post('/fairness/rotate', async (req, res) => {
  const userId = req.user.id;
  const client = await pool.connect();
  try {
    await client.query('BEGIN');
    const st = (await client.query(
      'SELECT active_index FROM fairness_state WHERE user_id=$1 FOR UPDATE',
      [userId])).rows[0];
    const oldIdx = st.active_index;

    // Reveal the old seed
    const old = (await client.query(
      `UPDATE fairness_seed SET revealed_at = now()
        WHERE user_id=$1 AND idx=$2 RETURNING server_seed, commitment`,
      [userId, oldIdx])).rows[0];

    // Advance to the next pre-committed seed, reset nonce
    await client.query(
      `UPDATE fairness_state
          SET active_index = active_index + 1, nonce = 0,
              client_seed = COALESCE($2, client_seed)
        WHERE user_id = $1`,
      [userId, req.body.newClientSeed ?? null]);

    await client.query('COMMIT');
    res.json({
      revealedServerSeed: old.server_seed,
      revealedServerSeedHash: old.commitment, // === sha256(server_seed)
      nextCommitment: /* sha256 of new active seed */ undefined,
    });
  } catch (e) { await client.query('ROLLBACK'); throw e; }
  finally { client.release(); }
});

Seed rotation and exposure limits

Because seeds come from the reverse chain, the newly-activated seed’s commitment was already provable before rotation: sha256(newSeed) equals the seed the player just received in the reveal. The chain closes on itself. This is stronger than plain commit-reveal, where each new commitment is an unverifiable fresh promise until its own reveal. If you also settle payouts in crypto, pairing rotation events with on-chain confirmations is a natural extension – the same audit mindset that governs iGaming crypto payment processors applies to seed lifecycle logging.

Step 13: Idempotent Nonce Counter & Concurrency Control

Two simultaneous bets from the same player must never receive the same nonce – that would produce two bets with identical outcomes and hand a savvy player free information. A read-then-write in application code races. The fix lives in the database.

Atomic nonce increment

// src/db/nonce.ts
export async function nextNonce(userId: number) {
  const { rows } = await pool.query(
    `UPDATE fairness_state
        SET nonce = nonce + 1
      WHERE user_id = $1
      RETURNING (nonce - 1) AS nonce, active_index, client_seed`,
    [userId]);
  return rows[0]; // atomic: each concurrent caller gets a distinct nonce
}

A single UPDATE ... RETURNING is atomic under Postgres’ MVCC – concurrent transactions serialise on the row lock and each receives a unique nonce. No application-level mutex, no Redis lock, no race. For broader market context, see our coverage of leading online casino sites.

Idempotency keys

Network retries are inevitable. Give every bet request a client-supplied idempotency key (a UUIDv7 from crypto.randomUUID() works well) and enforce it with the UNIQUE (user_id, idx, nonce) constraint plus a lookup table. If the same key arrives twice, return the stored outcome instead of consuming a new nonce. This is the difference between a demo and something that survives a mobile client on flaky 5G.

// place a dice bet, fully idempotent
router.post('/bet/dice', validate(betSchema), async (req, res) => {
  const { nonce, active_index, client_seed } = await nextNonce(req.user.id);
  const seed = await activeServerSeed(req.user.id, active_index);
  const result = dice(seed, client_seed, Number(nonce));
  await recordBet(req.user.id, active_index, nonce, 'dice', client_seed, result);
  res.json({ result, nonce, clientSeed: client_seed,
             serverSeedHash: sha256(seed) });
});

Step 14: The Standalone Independent Verifier

The verifier is the point of the entire exercise. It must reproduce outcomes using only public inputs plus the revealed seed, and it must not import any of your server’s stateful code – otherwise a bug in your engine would be copied into the check. Ship it as a self-contained script (and ideally publish it so third parties like the community tools at rakestake/provably-fair-verifier can cross-check you). If you’d rather lean on a hosted checker than stand up your own, Provable.io was still offering 10,000 free API requests per day as of August 2026 for exactly this kind of independent cross-verification.

The verifier module

// src/verify.ts – run: npx tsx src/verify.ts <serverSeed> <clientSeed> <nonce> <game>
import { createHmac, createHash } from 'node:crypto';

const sha256 = (s: string) => createHash('sha256').update(s).digest('hex');

function* bytes(ss: string, cs: string, nonce: number, cursor = 0) {
  let round = Math.floor(cursor / 32), rc = cursor % 32;
  while (true) {
    const buf = createHmac('sha256', ss).update(`${cs}:${nonce}:${round}`).digest();
    while (rc < 32) { yield buf[rc]; rc += 1; }
    rc = 0; round += 1;
  }
}
function oneFloat(ss: string, cs: string, nonce: number) {
  const g = bytes(ss, cs, nonce);
  let v = 0;
  for (let j = 0; j < 4; j += 1) v += g.next().value / 256 ** (j + 1);
  return v;
}

const [ss, cs, nonceStr, game] = process.argv.slice(2);
const f = oneFloat(ss, cs, Number(nonceStr));
const out = game === 'roulette' ? Math.floor(f * 37) : Math.floor(f * 10001) / 100;
console.log('serverSeedHash:', sha256(ss));
console.log('float:', f, '\noutcome:', out);

A player-facing verification widget

Front-ends should let a player paste the revealed server seed, their client seed and a nonce, and see the recomputed outcome next to the recorded one, plus a green check that sha256(serverSeed) equals the commitment they were shown before the bet. The exact same 40 lines of logic run in the browser. When those two panels match, you have delivered the only guarantee that actually matters.

$ npx tsx src/verify.ts 3a7f...e1 my-client-seed 42 dice
serverSeedHash: 9c12...bb
float: 0.5273441...
outcome: 52.73

Complete Project Example: Wiring the Service Together

The Express 5 entrypoint ties the modules into a running service. Express 5 finally handles async errors natively – a rejected promise in a route handler propagates to your error middleware without the old express-async-errors shim.

// src/api/server.ts
import express from 'express';
import { z } from 'zod';
import { pool } from '../db/pool.js';
import routes from './routes.js';

const app = express();
app.use(express.json());
app.use('/api', routes);

// Express 5: async errors are caught automatically
app.use((err: unknown, _req: express.Request, res: express.Response,
         _next: express.NextFunction) => {
  console.error(err);
  res.status(500).json({ error: 'internal' });
});

app.listen(3000, () => console.log('provably-fair on :3000'));

A minimal end-to-end flow a new user experiences: the service builds their reverse chain and stores it, exposes chain[0] as genesis, activates seed index 1 and publishes sha256(seed1) (which equals genesis), accepts dice bets that atomically burn nonces, and – on rotation – reveals seed 1 and advances to seed 2 whose commitment was already implied by the chain. A determinism test locks the whole thing down:

// src/rng.test.ts
import { expect, test } from 'vitest';
import { dice } from './games/dice.js';

test('same inputs → same outcome, always', () => {
  const a = dice('server-seed-abc', 'client-xyz', 7);
  const b = dice('server-seed-abc', 'client-xyz', 7);
  expect(a).toBe(b);                       // determinism
  expect(a).toBeGreaterThanOrEqual(0);
  expect(a).toBeLessThanOrEqual(100);
});

Validating Uniformity: Chi-Square and Histogram Tests

Reproducibility proves you did not cheat a specific bet. It does not prove your mapping is statistically uniform across millions of bets – a subtly biased float-to-outcome function can be perfectly reproducible and still favour the house. Before you ship, run a chi-square goodness-of-fit test on each game’s outcome distribution. This is a step certified labs perform and self-hosted implementations almost always skip. Tech Insider’s June 2026 RNG-testing guidance puts the floor for a credible NIST-style randomness run at 100,000 outcomes per game; the million-roll chi-square test below clears that bar by an order of magnitude.

A chi-square test over a million rolls

// src/fairness.test.ts
import { expect, test } from 'vitest';
import { roulette } from './games/roulette.js';

test('roulette is uniform over 0..36 (chi-square)', () => {
  const N = 1_000_000, k = 37, expected = N / k;
  const counts = new Array(k).fill(0);
  for (let n = 0; n < N; n += 1) {
    counts[roulette('seed-fixed', 'client', n)] += 1;
  }
  const chi = counts.reduce((s, c) => s + (c - expected) ** 2 / expected, 0);
  // 36 d.o.f., p=0.01 critical value ≈ 58.6; a fair RNG stays well under it
  expect(chi).toBeLessThan(58.6);
});

Reading the result

A fair generator produces a chi-square statistic comfortably below the critical value for the degrees of freedom (36 for roulette). If your statistic spikes, you have a mapping bug – almost always a stray modulo or an off-by-one in the range multiplier. Run the same test against dice buckets and Fisher-Yates position frequencies. Pair it with a simple histogram dump so a reviewer can eyeball the distribution: a flat bar chart is the visual signature of a bias-free engine. Keeping these tests in CI means a future refactor cannot silently reintroduce bias – the build fails first. That guarantee, encoded as a test rather than a PDF certificate, is the modern expression of what RNG auditing has always tried to deliver.

Beyond the Standard Coverage: What the Guides Miss

Search “provably fair” and you get three kinds of article: oracle-vendor marketing (Chainlink’s 6,600-word piece pushing on-chain VRF), operator explainers (gamingtec, BGaming – conceptual, no runnable code), and calculators. Having scraped the top-ranking results, here is the concrete gap inventory this tutorial closes.

The five things nobody explains with code

  • Modulo bias. Every generic guide says “hash the seed and take a modulo.” None mention that % N on raw bytes is biased. We use the base-256 float method the production engines actually ship.
  • The cursor. Guides show one HMAC call, then wave at “more bytes if needed.” The clientSeed:nonce:round streaming construction – the reason one triple yields unlimited bytes – is almost never written out.
  • Deterministic shuffles. Card and mines games need Fisher-Yates driven by the byte stream. Not one top-10 result shows a correct provably-fair shuffle.
  • Concurrency. The nonce race that lets two simultaneous bets collide is invisible in every tutorial; the atomic UPDATE ... RETURNING fix is production-critical.
  • Hash-chain pre-commitment. Chainlink pushes VRF (real gas costs, on-chain latency); the off-chain reverse hash chain that 95% of crypto casinos actually run is left unexplained.

Why VRF is not the default answer

Chainlink VRF – now at version 2.5, per Tech Insider’s June 2026 survey of on-chain randomness alternatives – is genuinely excellent for on-chain games where the contract itself must consume randomness trustlessly – but it costs LINK per request, adds block-confirmation latency measured in seconds, and is overkill for a centralised game server that settles off-chain. The HMAC commit-reveal model gives you sub-millisecond outcomes, zero per-bet cost, and equivalent verifiability for the operator-player relationship. Reach for VRF when the smart contract is the untrusted party; reach for this design when you are.

5 Critical Pitfalls That Break Provable Fairness

Each of these has shipped to production somewhere and each silently voids the fairness guarantee.

Pitfall 1–3: RNG and mapping

  1. Using Math.random() anywhere in the pipeline. It is not cryptographic, its outputs are predictable from a handful of samples, and it makes the whole scheme theatre. Only node:crypto primitives touch a seed.
  2. Modulo on raw bytes. byte % 6 biases low results. On a house doing millions of bets, that is a measurable, and eventually litigable, edge. Use the float method or rejection sampling.
  3. Applying house edge inside the RNG. Never nudge the random draw. Uniformity must be provable; edge belongs entirely in the payout table. Skewing the RNG is indistinguishable from cheating under audit.

Pitfall 4–5: Lifecycle and state

  1. Revealing a seed before all its bets are settled. If a player can obtain the active server seed mid-nonce, they can compute future outcomes and only bet on winners. Reveal strictly on rotation, after the seed is retired.
  2. Non-atomic nonce increment. Read-modify-write in app code races under load, producing duplicate nonces and duplicate outcomes. The counter must increment atomically in the database, full stop.

8 Troubleshooting Scenarios and Fixes

Symptom Likely cause Fix
Verifier hash ≠ commitment Encoding mismatch (utf8 vs hex) when hashing the seed Hash the seed as the exact string you committed; keep it hex end-to-end
Outcome differs between server and verifier Message string format drift (e.g. missing :round) Both sides must use clientSeed:nonce:round verbatim
Slight bias in outcome histogram Modulo on raw bytes Switch to the base-256 float mapping
Two bets with identical result Nonce race / retried request reused nonce Atomic UPDATE ... RETURNING + idempotency key
Shuffle not reproducible Reused a float or used Math.random for a swap One fresh float per Fisher-Yates step, downward iteration
Chain verification fails after rotation Advanced index without revealing prior seed Reveal old seed inside the same transaction as the index bump
Crash multiplier occasionally Infinity f === 1 edge (never happens, but guard anyway) Clamp f to [0, 1); the float method already excludes 1
HMAC differs across languages Server seed passed as bytes in one impl, string in another Standardise the key encoding; hex string is safest cross-runtime

Advanced Tips: On-Chain Anchoring and Provably-Fair 2.0

The 2026 frontier is “Provably Fair 2.0” – moving the commitment on-chain so the operator cannot quietly discard an inconvenient genesis. You do not need to run every bet through a smart contract to benefit; you can anchor just the chain genesis.

Anchor the genesis hash on-chain

Publish each user’s (or each daily batch’s) chain[0] as an event or calldata on a cheap L2. The cost is a single transaction per chain, not per bet, and it gives players a timestamped, immutable proof that the genesis existed before any round – closing the theoretical “operator regenerates the chain” attack. It is the pragmatic middle ground between fully off-chain HMAC and fully on-chain VRF.

Publish and version your algorithm

Ship the verifier as an open-source package with a pinned version string embedded in every bet receipt ("pf": "v1"). When you change a formula, bump the version and keep old verifiers runnable. Auditors under MGA and UKGC technical standards increasingly expect a documented, reproducible mapping – the reproducibility is the compliance artifact, and it costs you nothing to maintain once the test suite exists.

Rate-limit reveal endpoints

The reveal endpoint exposes a secret. Rate-limit it, log every reveal to an append-only audit table, and never allow a reveal for a seed with unsettled bets. Treat the reveal path with the same rigour you would a withdrawal endpoint.

Frequently Asked Questions

Is provably-fair RNG legal / accepted by regulators?

Provably-fair is a transparency mechanism, not a licence substitute. Regulators like the MGA and UKGC still require RNG documentation and often independent testing, but a reproducible provably-fair implementation strengthens an audit rather than replacing it – independent auditor Intelliverse lists its most recent RNG audit as of 5/28/2026, a reminder that third-party testing keeps running alongside self-hosted provably-fair proofs, not instead of them. Most crypto operators run it alongside a Curaçao or Anjouan licence.

Can the operator still cheat a provably-fair game?

Not on the outcome of a committed bet – SHA-256 makes changing a seed after commitment infeasible. The residual risk is elsewhere: a rigged client seed default, revealing seeds early, or applying edge inside the RNG. Follow the pitfalls section and those vectors close.

Why HMAC-SHA256 and not plain SHA-256?

HMAC keys the hash with the secret server seed, so the client seed and nonce (public) cannot be used to derive the output without the key. Plain SHA-256 of concatenated inputs is more vulnerable to length-extension and offers no key separation. HMAC (RFC 2104) is the industry standard for exactly this reason.

What is the cursor for?

It streams more than 32 bytes from one (serverSeed, clientSeed, nonce) triple. round = floor(cursor/32) goes into the HMAC message, so each 32-byte block is an independent HMAC. A dice roll needs 4 bytes; a card shuffle needs hundreds – the cursor makes both deterministic and verifiable.

Do I need blockchain or a smart contract?

No. The HMAC commit-reveal model is entirely off-chain and is what the large majority of crypto casinos actually run. On-chain VRF (e.g. Chainlink) is only necessary when a smart contract itself is the untrusted consumer of randomness. Anchoring just the genesis hash on-chain is a low-cost optional hardening.

How often should server seeds rotate?

On player demand at minimum, and many operators auto-rotate after a fixed number of bets or a time window. Rotation limits the blast radius of any seed exposure and lets players verify their history in bounded batches. With a reverse hash chain, rotation is cheap because the next seed is already committed.

Does this work for slots and live-dealer games?

The RNG core works for any game reducible to uniform draws or shuffles – slots reels, keno, plinko, mines, blackjack. Live-dealer games use physical entropy (real cards/wheels) and rely on studio auditing instead, so provably-fair does not apply to them.

What performance can I expect?

A single HMAC-SHA256 on a modern server is microseconds; a dice outcome including the float extraction is comfortably sub-millisecond, and the bottleneck is your database write, not the crypto. The engine scales horizontally because each player’s chain and nonce are independent – no shared RNG state to contend on.

Casey Mitchell

Casey Mitchell

Crypto Casino Editor

Casey Mitchell tracks crypto casinos, on-chain gaming, provably fair RNG, and the regulatory gray zones around US-facing crypto gambling platforms. Reviews wallets, deposit flows, and KYC policies across BTC, ETH, and stablecoin operators.

View all articles