How browser game leaderboards get cheated — and what we do about it
Every browser game with a global high score table faces the same problem: the game runs on the player's machine, and the player owns that machine completely. Here's how the attacks actually work, and the design we use across our games to stay ahead of them.
The single most common mistake in a hobby browser game is a leaderboard that works like
this: the player finishes a run, the page sends { name: "Ada", score: 41200 } to
a server, and the server writes it down. It's the obvious design. It's also completely
indefensible, and it will be broken by the first curious visitor who opens the developer
console.
Three ways a naive leaderboard falls over
1. Just posting the score
The attacker doesn't need to touch your game at all. They watch the network tab, see the request your game makes when a run ends, and then make that request themselves with a different number in it. This takes about ninety seconds and requires no programming. If your server accepts a score because it arrived in the right shape, you don't have a leaderboard — you have a form.
2. Editing the game in memory
Slightly more effort, and it defeats naive defences. The game's own variables live in the page, so anything the page can compute, the player can change. Set the score variable directly, make the player invulnerable, remove the enemies. Then let the game submit the score through its normal, "legitimate" path. Any check that runs in the browser is running inside the attacker's house.
3. Replaying a valid submission
This one catches people who have thought about the problem a little. Suppose you sign the score so it can't be edited in flight. Fine — but if the signature doesn't change, the attacker can capture one genuine good run and submit it a hundred times, or share it with friends. A signature proves a message wasn't tampered with. It doesn't prove the message is fresh, or that it's yours.
The rule that actually works
There's really only one durable principle, and everything else is an implementation of it: the server must never learn anything from the client that the client has a motive to lie about.
In practice that means the client is allowed to report intent and events, and the server decides what those are worth. The client may say "I mined the tile at column 84, row 512." It may not say "give me 240 credits." The client may say "my guess was hue 210, saturation 60, lightness 40." It may not say "my score was 9,000."
What that looks like in our games
The score is computed on the server, always
In Huedle, our daily colour puzzle, the browser never sends a score because the browser doesn't know one. It sends guesses. The server holds the day's target colour, compares each guess against it, works out the feedback you see, and — when the run ends — calculates the score itself from how many guesses you used and how close you got. There is no number in the request that a cheat could inflate, because the only numbers in the request are the colours you picked, and picking a good colour is the game.
The same principle runs through Strata, which is a much larger surface to defend because it has an economy. Ore prices, cargo capacities and upgrade costs all live in a table the server owns. When you sell, the server looks up the price. When you buy, the server checks you can afford it and applies the tier. A modified client can display whatever it likes; it cannot mint credits, because it was never the thing deciding how many credits you had.
Sessions are signed, and the signature is checked properly
Each run starts by asking the server for a session. The server generates a random session id,
signs it with HMAC-SHA256 using a secret only the server knows, and hands back
id.signature. Every subsequent request carries that token, and the server
recomputes the signature to confirm the id hasn't been altered.
Two details matter more than they look. The secret is a real secret, injected from our
secrets manager at deploy time — not a constant sitting in the repository, which would make
the whole scheme decorative. And the comparison is done with a constant-time equality check
rather than ===, so an attacker can't learn the signature a byte at a time by
measuring how long a rejection takes. That second one is genuinely obscure and costs one
function call to get right.
The server independently knows what the world contains
This is the strongest check we have, and it only works because of a decision made elsewhere. Strata's world is generated from a seed, deterministically, which means the server can rebuild the identical world and inspect it. So when a client claims a tile, the server regenerates that world, looks at the tile, and asks: was it really ore, and has it already been claimed? Both answers come from the server's own copy of reality.
The five Seals are checked the same way but harder — a Seal's identity is derived from the depth band its tile sits in, so the server doesn't just verify that you found a Seal, it works out which one from where it was. Announcing "I have all five" gets you nothing at all. (There's more on how the world is derived in our guide on building a fair procedural world.)
The things we deliberately don't do
It's worth being honest about the limits, because a lot of anti-cheat writing implies a completeness that nobody has.
- We don't obfuscate the game code. Minified JavaScript is not a security measure, it's a speed bump that costs you debuggability. If your defence relies on the attacker not reading your code, it has already failed.
- We don't try to detect "impossible" play. Heuristics like "nobody can score above X" or "that run was too fast" produce false positives against your best players, who are exactly the people you least want to accuse. We'd rather a suspicious score stand than ban a genuinely excellent one.
- We can't stop a patient human. Someone willing to actually play the game slowly and well, using a script to press the keys, is submitting genuine events for genuine tiles. At that point they're not cheating the server, they're automating themselves, and the honest answer is that this is a very expensive way to win a free browser game.
One trap we walked into
A practical warning, because we learned it the hard way. Because sessions are signed with a server secret, rotating that secret invalidates every token in flight. We rotated Strata's secret once during a deploy without thinking it through, and every player with an open tab silently became a brand-new miner — their session no longer verified, so the server issued them a fresh one, with no credits and no upgrades.
It's obvious in hindsight and completely invisible until it happens to somebody. If you build this, treat the game secret as long-lived infrastructure, not as a config value you can change during a routine deploy. The same is doubly true of Huedle, where the secret also determines the colour of the day — changing it mid-day changes the answer under everyone who has already started.
The short version
If you're building something similar, you can get most of the way there with three rules. Never accept a score — accept the events that produce one and compute it yourself. Give each run a signed session so requests can be tied to a specific, server-issued run. And wherever you can, arrange for the server to be able to independently verify the world the player is claiming things about, because a check the server can perform from its own data is worth ten checks that depend on the client being honest.