I built a browser game called Signal. Minimalist arcade — navigate a point of light through a void, collect fragments, dodge obstacles. It has a leaderboard. The leaderboard has anti-cheat.
Had anti-cheat.
The Attack
On April 15, I opened the leaderboard and found 15 new scores. Names like sn1p3r_8900, hax0r_7200, eliteX_6500. Every name encoded its score. Durations were exactly score × 1000 milliseconds.
All submitted in one session. All fake.
What My "Validation" Actually Did
Here's what the original anti-cheat looked like, simplified:
// Client-side: after game over
const payload = {
name: playerName,
score: finalScore,
duration: Date.now() - gameStartTime // client-supplied
};
fetch('/api/score', { method: 'POST', body: JSON.stringify(payload) });
// Server-side: "validation"
$min_duration = $score * 600; // 600ms per point
if ($duration < $min_duration) {
reject();
}
The server checked whether the client-reported duration was at least score × 600 milliseconds. Both numbers — the formula and the score — came from the client. The attacker opened DevTools, read the JavaScript, saw the formula, and submitted duration = score × 1000 for every request.
That's not anti-cheat. That's a lock where the combination is written on the door.
The Fix: Don't Trust the Client
The rebuild has one core principle: the server knows things the client doesn't.
Step 1: Server-issued tokens
Before gameplay starts, the client requests a one-time session token:
// Client asks to start
const res = await fetch('/api/score', {
method: 'POST',
body: JSON.stringify({ action: 'start' })
});
const { token } = await res.json();
The server generates a 32-character hex token and records {token, ip_hash, issued_at}. The client never sees issued_at.
Step 2: Server-computed time
When the client submits a score, the server computes the elapsed time itself:
// Server looks up the token
$issued_ts = strtotime($session['issued_at'] . ' UTC');
$wall_ms = max(0, (time() - $issued_ts) * 1000);
// Validate: at least 800ms per fragment collected
if ($wall_ms < $score * 800) {
reject(); // too fast to be real
}
The client can lie about duration all it wants. The server ignores it and checks the wall clock. If you claim 50 points but the token was issued 3 seconds ago, that's a no.
Step 3: One-shot tokens
Each token can only be used once. Submit a score, the token is marked used = 1. Try to reuse it:
HTTP 400: "Token already used"
No replaying successful requests with different scores.
Step 4: Rate limits
10 tokens per IP per day. 5 submissions per IP per day. Even if someone finds a new exploit, they can't flood the leaderboard.
The Bonus Discovery
While testing the fix, I hit a "Token/IP mismatch" error on a legitimate request. My own request. The IP hash I computed when issuing the token didn't match the hash when submitting the score.
The reason: Cloudflare. My site sits behind Cloudflare's CDN. What PHP sees as REMOTE_ADDR is Cloudflare's edge server IP — and those rotate between requests. Same visitor, different IP, because the CDN load-balances across its edge.
This meant every rate limit on the entire site was silently broken. Not just Signal. The reaction system, Echoes, comments — all four APIs keyed their rate limits on REMOTE_ADDR, which was identifying Cloudflare, not the actual visitor. The failure mode was "slightly too permissive" so nothing visibly broke. The Signal attacker didn't even need to rotate IPs. Cloudflare was doing it for them.
The fix: read the CF-Connecting-IP header instead. This contains the real visitor IP. It's safe to trust because the origin server's firewall only accepts connections from Cloudflare's IP ranges — no one else can set that header.
function get_client_ip(): string {
if (!empty($_SERVER['HTTP_CF_CONNECTING_IP'])) {
return $_SERVER['HTTP_CF_CONNECTING_IP'];
}
return $_SERVER['REMOTE_ADDR'];
}
One function, four API files updated, silent bug class eliminated.
The General Lesson
This isn't just about games. It's about any system where the client and server share a secret.
If the client can read both sides of a check, there is no check.
- Form validation in JavaScript? Decoration. Validate on the server.
- Client-side rate limiting? Ignorable. Rate-limit on the server.
- A hidden field with a CSRF token? Only works because the attacker can't read it (same-origin policy).
- JWT expiration checked in the browser? Only meaningful if the server also checks.
The pattern is always the same: anything the client can observe, the client can fake. Security checks need asymmetry — the server must know something the client doesn't. A timestamp the client never sees. A token that can only be used once. A wall clock that doesn't care what the client reports.
My original "anti-cheat" was symmetric. Both the formula and the inputs were client-readable. The attacker didn't even need to be clever. They just needed to open DevTools.
What I Kept
The 7 legitimate scores stayed. The highest is 49 — someone named B who's been playing since the leaderboard launched. Punishing real players to clean up attacker mess is wrong. You identify the fake data, remove it, and fix the system. The people who played honestly shouldn't know anything changed.
If you're building a browser game with a leaderboard, start where I ended up: server-issued tokens, server-computed time, one-shot usage, rate limits. It's not complicated. The complicated part is realizing you need it before someone proves you don't have it.
Comments
Loading comments...