Cloudflare Workers Leaderboard API with Anti-Cheat
A Cloudflare Workers leaderboard cannot trust the score a browser sends it, but it can make cheating expensive. Sign every submission with HMAC, hand out a single-use run ID when a game starts, time the run on the server so the score has to fit the time played, and cap how often each player can submit. This post builds that API with one Worker, one Durable Object per leaderboard and a KV cache for reads, and includes the Vitest suite that proves each check rejects what it should.
Why Client-Submitted Scores Can't Be Trusted
In a browser game the score lives in memory the player controls. Whatever fetch call posts it to your API, the player can see it in the Network tab, change it and send it again. There are three cheap attacks, and a naive POST /scores endpoint falls to all of them:
- Edit the number. Change
"score": 1200to"score": 999999and resend the request from DevTools. - Replay a good run. Capture one legitimate high-score request and send it again, from another account or a thousand times.
- Script it. Skip the game and call the API from
curlin a loop.
Most Durable Objects leaderboard tutorials stop at storing and sorting scores, which is the easy part. Game leaderboard anti-cheat is the hard part, and it is where a public board lives or dies: the first time the top spot shows an impossible score, players stop taking it seriously.
We built the Edge Leaderboard for browser games on Workers and KV, with score validation and rate limiting across multiple games. The code below is a standalone version written for this post, not that production code, and it uses a Durable Object for the write path. Every snippet comes from a project that passes its tests under @cloudflare/vitest-plugin.
Architecture: a Durable Object per Leaderboard, KV for Reads
Three pieces, each doing the job it is good at:
- The Worker routes requests and checks the HMAC signature. It holds no state, so it runs in whichever Cloudflare location is closest to the player.
- One Durable Object per leaderboard, addressed by name (
snake:global,snake:2026-w41). It owns the open runs, the rate-limit counters and the scores, in its built-in SQLite database. - KV holds a copy of the top 100 for 60 seconds, so reads skip the Durable Object while the cache is warm.
The Durable Object does the writes because it is single-threaded. Two requests trying to spend the same run ID are processed one after the other, so the second always sees that the first already deleted it. There is no lock to write and no race to test for. KV cannot give you that: it is eventually consistent, concurrent writes to the same key can overwrite each other, and it accepts one write per second to a given key. Use it as the source of truth for anti-cheat state and a replay sent twice at the same instant can be accepted twice.
The trade-off is location and throughput. A Durable Object lives in one place, so a player on the other side of the world pays the round trip on every submission, and Cloudflare documents a soft limit of 1,000 requests per second per object. A leaderboard only writes when a game ends, which keeps most boards far from that; if one gets close, shard it by region or by time period. On the read side, the KV copy can be up to a minute behind: it lives 60 seconds (the minimum expirationTtl), and a KV write can take up to 60 seconds to reach other locations. For a high-score table that is fine. A player who needs their result immediately gets it from the submit response, which returns their best score straight from the Durable Object.
The wrangler.jsonc configuration binds the Durable Object and the KV namespace, and uses the exports block to give the class SQLite storage:
{
"$schema": "node_modules/wrangler/config-schema.json",
"name": "edge-leaderboard-example",
"main": "src/index.ts",
"compatibility_date": "2026-09-20",
"durable_objects": {
"bindings": [{ "name": "LEADERBOARD", "class_name": "Leaderboard" }]
},
"exports": {
"Leaderboard": { "type": "durable-object", "storage": "sqlite" }
},
"kv_namespaces": [{ "binding": "TOP_CACHE", "id": "replace-with-your-kv-namespace-id" }]
}
The Durable Object creates its three tables on first use. CREATE TABLE IF NOT EXISTS in the constructor is cheap and means there is no separate schema migration to run:
constructor(ctx: DurableObjectState, env: Env) {
super(ctx, env);
this.sql = ctx.storage.sql;
this.sql.exec(`
CREATE TABLE IF NOT EXISTS runs (id TEXT PRIMARY KEY, player TEXT NOT NULL, started_at INTEGER NOT NULL);
CREATE TABLE IF NOT EXISTS submits (player TEXT NOT NULL, at INTEGER NOT NULL);
CREATE TABLE IF NOT EXISTS scores (player TEXT PRIMARY KEY, score INTEGER NOT NULL, at INTEGER NOT NULL);
CREATE INDEX IF NOT EXISTS scores_rank ON scores (score DESC, at ASC);
`);
}
Step 1: A Signed Submit Endpoint with Replay Protection
The flow takes two requests. When a game starts, the client asks for a run ID. When it ends, the client posts the score with that run ID and an HMAC signature of the body.
Issue a Run ID When the Game Starts
POST /v1/snake/global/runs with a player ID returns a fresh run ID. The Worker validates the ID and hands the work to the board's Durable Object:
if (action === 'runs' && request.method === 'POST') {
const { playerId } = await request.json<{ playerId?: string }>();
if (!playerId || !PLAYER_ID.test(playerId)) return json({ error: 'bad playerId' }, 400);
return json({ runId: await stub.startRun(playerId, rules.maxRunMs) }, 201);
}
startRun(player: string, maxRunMs: number): string {
const now = Date.now();
this.sql.exec('DELETE FROM runs WHERE started_at < ?', now - maxRunMs);
const id = crypto.randomUUID();
this.sql.exec('INSERT INTO runs (id, player, started_at) VALUES (?, ?, ?)', id, player, now);
return id;
}
The run ID does two jobs. It is the nonce for replay protection, and it records when the run started on the server's clock, which step 2 depends on. The DELETE at the top sweeps runs older than the game's maximum length, so abandoned games do not pile up.
Verify the HMAC Signature
The Worker checks the signature over the raw request body before it parses anything. Sign and verify the exact bytes that went over the wire: re-serialising parsed JSON can reorder keys or change number formatting, and the signature stops matching.
async function verifySignature(secret: string, body: string, signatureHex: string | null): Promise<boolean> {
if (!signatureHex || !/^[0-9a-f]{64}$/.test(signatureHex)) return false;
const enc = new TextEncoder();
const key = await crypto.subtle.importKey('raw', enc.encode(secret), { name: 'HMAC', hash: 'SHA-256' }, false, ['verify']);
const signature = Uint8Array.from(signatureHex.match(/../g)!, (h) => parseInt(h, 16));
return crypto.subtle.verify('HMAC', key, signature, enc.encode(body));
}
crypto.subtle.verify does the comparison itself, so there is no hand-written string comparison to leak timing. On the client, the same Web Crypto API produces the hex signature sent in the X-Signature header. This is the function the test suite signs with, and it runs unchanged in a browser:
async function sign(secret: string, body: string): Promise<string> {
const enc = new TextEncoder();
const key = await crypto.subtle.importKey('raw', enc.encode(secret), { name: 'HMAC', hash: 'SHA-256' }, false, ['sign']);
const mac = await crypto.subtle.sign('HMAC', key, enc.encode(body));
return [...new Uint8Array(mac)].map((b) => b.toString(16).padStart(2, '0')).join('');
}
The key is a Worker secret: npx wrangler secret put SCORE_SIGNING_KEY in production, a line in .dev.vars for npx wrangler dev. The client needs the same key, which is the weakness covered in the limits section below.
Spend the Run ID Exactly Once
Inside the Durable Object, replay protection is one SQL statement. DELETE ... RETURNING removes the run and hands back its start time in the same step. A second submission with the same run ID finds nothing and gets a 409. The player = ? condition stops one player from spending another player's run.
// Replay protection: a run ID is a single-use nonce, consumed here.
const run = this.sql
.exec<{ started_at: number }>('DELETE FROM runs WHERE id = ? AND player = ? RETURNING started_at', runId, player)
.toArray()[0];
if (!run) return { ok: false, status: 409, error: 'unknown or already used run' };
Because the run ID is issued by the server and stored in the board's own Durable Object, a request captured on one board is also useless on any other: that object has never seen the ID.
Step 2: Plausibility Bounds and a Per-Player Rate Limit
This is the core of any score validation API. A valid signature only proves the request came from something holding the key; the plausibility check asks whether the score could have been earned. Each game declares its own bounds:
const GAMES: Record<string, Rules> = {
snake: { maxScore: 10_000, maxPointsPerSecond: 20, maxRunMs: 60 * 60 * 1000 },
};
maxPointsPerSecond does most of the work. Set it from real play: take the fastest legitimate runs you have seen and leave headroom, because a bound that rejects your best players is worse than no bound. The check uses the run's start time from the server, never a duration reported by the client:
// Plausibility: the server timed the run, so the client cannot lie about its length.
const elapsedMs = now - run.started_at;
if (!Number.isInteger(score) || score < 0 || score > rules.maxScore)
return { ok: false, status: 422, error: 'score out of range' };
if (elapsedMs > rules.maxRunMs) return { ok: false, status: 422, error: 'run expired' };
if (score > (elapsedMs / 1000) * rules.maxPointsPerSecond)
return { ok: false, status: 422, error: 'score too high for run length' };
A client that claims a ten-minute run changes nothing. The server knows the run started ten seconds ago and caps it at 200 points for this game. There is no start time to forge either, because the client never sends one.
The rate limit runs first, before the run lookup, so a flood of submissions costs one count query each. Every signed attempt counts, accepted or rejected, so a script cannot probe the bounds with rapid retries. SUBMITS_PER_MINUTE is 5 in this example:
// Rate limit: every signed attempt counts, accepted or not.
this.sql.exec('DELETE FROM submits WHERE at < ?', now - 60_000);
const recent = this.sql.exec<{ n: number }>('SELECT COUNT(*) AS n FROM submits WHERE player = ?', player).one().n;
if (recent >= SUBMITS_PER_MINUTE) return { ok: false, status: 429, error: 'too many submissions' };
this.sql.exec('INSERT INTO submits (player, at) VALUES (?, ?)', player, now);
This limit is per player and per board, and it is exact because the Durable Object handles one request at a time. For limits per IP address or across all boards, Cloudflare's Rate Limiting binding or a WAF rule in front of the Worker is the better tool. The route maps each rejection to its own status code, so the game can tell the player what happened:
if (action === 'scores' && request.method === 'POST') {
const body = await request.text();
if (!(await verifySignature(env.SCORE_SIGNING_KEY, body, request.headers.get('X-Signature'))))
return json({ error: 'bad signature' }, 401);
const { playerId, runId, score } = JSON.parse(body);
if (!PLAYER_ID.test(playerId) || typeof runId !== 'string' || typeof score !== 'number')
return json({ error: 'bad payload' }, 400);
const result = await stub.submit(playerId, runId, score, rules);
if (!result.ok) return json({ error: result.error }, result.status);
return json({ best: result.best }, 201);
}
Want more like this?
New posts in your inbox, a few a month.
Step 3: Reading the Top N
Each player keeps one row, their best score. The upsert in submit only overwrites it when the new score is higher, and ties go to whoever got there first. Reading the board is one indexed query:
top(n: number): { player: string; score: number }[] {
return this.sql
.exec<{ player: string; score: number }>('SELECT player, score FROM scores ORDER BY score DESC, at ASC LIMIT ?', n)
.toArray();
}
The Worker serves reads from KV and only falls through to the Durable Object on a miss, writing the fresh list back with ctx.waitUntil so the response does not wait for the write:
if (action === 'top' && request.method === 'GET') {
const n = Math.min(Math.max(Number(url.searchParams.get('n')) || 10, 1), 100);
const key = `top:${game}:${board}`;
let top = await env.TOP_CACHE.get<{ player: string; score: number }[]>(key, 'json');
if (!top) {
top = await stub.top(100);
ctx.waitUntil(env.TOP_CACHE.put(key, JSON.stringify(top), { expirationTtl: TOP_CACHE_TTL }));
}
return json(top.slice(0, n));
}
It always caches the top 100 and slices on the way out, so ?n=10 and ?n=50 share one cache entry.
Testing the Anti-Cheat Checks
Anti-cheat code without tests is a guess. The suite runs inside the Workers runtime with @cloudflare/vitest-plugin and calls the Worker through exports.default.fetch. One snag: the plausibility check needs real elapsed time, and a test should not wait two minutes. runInDurableObject reaches the object's storage directly, so the test moves each open run's start time back instead:
// Pretend every open run on this board started `ms` ago (a real player would have been playing).
async function ageRuns(board: string, ms: number) {
const stub = env.LEADERBOARD.getByName(`snake:${board}`);
await runInDurableObject(stub, (_instance, state) => {
state.storage.sql.exec('UPDATE runs SET started_at = started_at - ?', ms);
});
}
it('rejects a replayed run ID', async () => {
const runId = await startRun('replay', 'carol');
await ageRuns('replay', 120_000);
const payload = { playerId: 'carol', runId, score: 800 };
expect((await submit('replay', payload)).status).toBe(201);
const again = await submit('replay', payload);
expect(again.status).toBe(409);
expect(await again.json()).toEqual({ error: 'unknown or already used run' });
});
it('rate limits a player to 5 submissions a minute', async () => {
const statuses: number[] = [];
for (let i = 0; i < 6; i++) {
const runId = await startRun('ratelimit', 'frank');
statuses.push((await submit('ratelimit', { playerId: 'frank', runId, score: 0 })).status);
}
expect(statuses).toEqual([201, 201, 201, 201, 201, 429]);
});
The full suite has eight tests, run with npx vitest run: a valid score is accepted; a tampered body and a wrong key both get 401; a replayed run ID gets 409; a score too high for the run's length and a score above the game maximum both get 422; the sixth submission in a minute gets 429; and the top list comes back in order and lands in KV. To check the tests are not passing by accident, disable each guard in turn (the signature check, the run deletion, the rate limit, the points-per-second bound) and the matching test fails.
What This Approach Cannot Stop
This setup stops the cheap attacks from the first section: editing a request, replaying one, and scripting submissions without playing. It does not stop a determined cheater, for three reasons.
The Signing Key Ships with the Game
For a browser game to sign a submission, the key has to be in the JavaScript it downloads. Anyone who reads the source can find it, and with it they can sign any payload they like. Obfuscation and WebAssembly slow that down; they do not prevent it. Treat the HMAC as a filter against casual tampering and request-editing tools, not as proof that your client sent the request.
A Patient Cheat Passes Every Check
A modified client, or a bot that plays at a believable pace and submits believable scores, passes the signature, the run ID, the bounds and the rate limit. The bounds only cap how far above legitimate play a fake score can sit. The real fix for a competitive game is to stop trusting the client's result at all: send the inputs, or the random seed plus the moves, and re-run the game logic on the server so the score is computed rather than reported. That is a much bigger job, worth it when the leaderboard carries prizes.
One Player, Many Accounts
The rate limit is per player ID, and player IDs are cheap. Put a per-IP limit on run creation, tie player IDs to real sign-ins where the game has them, and review the top entries before they matter. The checks here reject impossible scores; they cannot judge suspicious ones, such as a new account that jumps straight to first place. That needs anomaly detection over a player's history, a separate system from the submit path.
FAQ
Can I build a Cloudflare Workers leaderboard with KV alone?
For a board where cheating does not matter, yes: keep the top list in one key and rewrite it on each new score. For anti-cheat, no. Replay protection and rate limiting need an atomic check-and-update, and KV is eventually consistent with a limit of one write per second per key, so two identical submissions arriving together can both pass. A Durable Object processes them one at a time.
Why does the server time the run instead of the client?
Anything the client reports, a duration or a timestamp, can be edited as easily as the score. The run ID is created with the server's clock, so the elapsed time in the plausibility check is the one value in the submission the player never touches.
How do I reset a leaderboard every week?
Put the period in the board name: snake:2026-w41. Each name maps to its own Durable Object through getByName, so a new week starts with an empty board and old weeks stay readable for history pages. Nothing has to be deleted at midnight.
Does hiding the key in WebAssembly make the HMAC secure?
No. It raises the effort needed to extract the key, which filters out casual cheaters, but the key still has to exist on the player's machine for the game to sign anything. Keep the server-side checks (single-use run IDs, server-timed plausibility, rate limits) as the real defence and treat the signature as one layer of several.
Related Articles
- Building a Telegram-to-CLI Bridge for Claude Code: another Cloudflare Workers build, using KV as a message queue between Telegram webhooks and a desktop daemon.
- XNDR services: we design and build edge APIs like this one, from the first Worker to production.
Liked this one? Get the next in your inbox.
New posts and the odd product note, a few emails a month. Easy to leave.