How I stop cheaters on a browser game leaderboard (without CAPTCHA)
Here is an uncomfortable truth about browser games: your game runs on the player's machine, which means the player controls everything. The score, the timer, the physics — all of it lives in a JavaScript context that anyone can open DevTools on. The moment my arcade got a daily leaderboard, it got a target painted on it.
My games are small free things, so I didn't want to punish honest players with CAPTCHAs or install some heavy bot-detection script. Instead I built a few server-side layers that make cheating annoying enough that nobody has bothered. Here's the whole system.
Rule 1: the request body is a liar
The first version of a score submission endpoint that everyone writes
looks like this: the client POSTs { username, score } and the
server saves it. That's not a leaderboard, that's a public guestbook. Anyone
with curl can be number one.
So the first fix: identity comes from the session cookie, never from the request body. You log in, you get an httpOnly cookie, and when a score comes in, the server looks up who you are from that cookie. The request body only ever says "this many points" — it never gets to say who scored them. That kills impersonation completely.
Rule 2: no score without a run token
That still leaves a logged-in cheater submitting scores for games they
never played. My answer is a run token: before a game starts, the
client calls /api/run/start and the server hands back a random
token, stored in Redis with a timestamp, bound to that specific account and
that specific game.
When the score comes in, the token must come with it. The server claims it
atomically — Redis has a GETDEL command that reads and deletes
in one operation, so a token can only ever be used once, even if two requests
race for it. Wrong account? Rejected. Wrong game? Rejected. Already used?
Rejected. Replaying an old winning submission does nothing.
Rule 3: could a human actually do this?
The token's timestamp gives me one more free check: elapsed time. The server knows exactly when the run started, so when a score arrives it asks a simple question — is this score achievable in that much time?
Every game in my registry declares two numbers: a maximum plausible score, and the minimum milliseconds a real player needs per point. In snake you physically cannot eat 500 apples in nine seconds. A submission that violates either limit gets dropped. Tuning these numbers is honestly the fun part — you play your own game as sweatily as you can, look at your rate, and give the limit maybe double that as headroom.
The boring rejections that matter
Alongside the clever stuff, the server also refuses: negative scores, floating-point scores, scores over the per-game cap, and submissions with no valid session at all. None of this is glamorous but each one closes a door. During a security pass on the arcade, the pen-test checklist (IDOR, auth bypass, injection, replay) came back clean — mostly because of these dumb little checks, not the fancy ones.
What I knowingly left open
Full honesty: a patient cheater could still script the actual gameplay — drive the real game with synthetic inputs at human speed, get a real token, submit a real-looking score. Stopping that means CAPTCHAs or behavioural analysis, and for a free arcade that's a terrible trade. If someone writes a human-paced snake bot to top my daily leaderboard, honestly, they've earned it. I'd probably email them.
The goal was never "unbeatable". The goal was making the lazy attacks — curl, replay, DevTools score editing — completely dead. Those are the ones that actually happen.