Browser games have used top-lists for twenty years. A player clicks "Vote for us", lands on the list, votes, and the game gives them a little something in return: some in-game cash, an energy refill, a badge. It is a fair deal. The game climbs the rankings, and the player gets a thank-you.
The weak spot is the thank-you. How does the game know the vote really happened?
The problem with "callback URLs"
The classic answer is a callback: after a vote, the top-list calls a URL on the game's server, something like https://mygame.com/vote.php?user=bob. The game sees the request and rewards Bob.
Anyone who has run a game with one of these knows what comes next. The URL leaks (it is often visible in a redirect, or guessed in five minutes), and a player opens it in a loop. Or someone rewards the account admin. Or the top-list retries a slow request and the player gets paid three times.
When we rebuilt TopRankinGames, a player-voted top-list for online games, we wanted rewards that a game could trust without trusting the player's browser at all. This is the design we ended up with.
The design
1. A player token, never a username. The game sends players to a vote link with a token it made up:
https://toprankingames.com/vote/your-game/?u=PLAYER_TOKEN
The token is any ID the game creates for that player: letters, numbers, dashes and underscores, up to 128 characters. A random string stored against the player works best. A username, email or password would leak something, and a guessable ID lets one player claim another's reward.
2. A server-to-server POST with a JSON body. When a vote counts, our server (not the player's browser) sends a POST to the game's callback URL:
{
"game": "your-game",
"vote_id": 123456,
"site_vote_id": "123456",
"token": "PLAYER_TOKEN",
"voted_at": 1793000000,
"nonce": "4f1c2a9e7b3d5a6c8e0f1a2b",
"sent_at": 1793000004
}
3. An HMAC-SHA256 signature over the raw body. Each game gets a secret key in its dashboard. Every callback carries a header:
X-Signature: sha256=<hex HMAC-SHA256 of the raw body, keyed with your secret>
Without the secret, nobody can produce a valid signature, so a leaked callback URL is worthless.
4. Timestamp and nonce. sent_at lets the game reject old requests that someone captured and replays later. nonce is unique per vote, so the game can store it and refuse a second payout.
5. Retries that are safe to repeat. We retry only after a 5xx or a timeout: after 30 seconds, 2 minutes, 10 minutes, 1 hour and 6 hours. Every retry has the same nonce and a new sent_at. A game that stores nonces can answer every retry and still pay exactly once.
The code
Here is the PHP check from our developer page:
<?php
$secret = getenv('TRG_SECRET'); // from your dashboard, never in public code
$body = file_get_contents('php://input');
$sig = $_SERVER['HTTP_X_SIGNATURE'] ?? '';
$expect = 'sha256=' . hash_hmac('sha256', $body, $secret);
$vote = json_decode($body, true);
if (!hash_equals($expect, $sig) || abs(time() - (int) ($vote['sent_at'] ?? 0)) > 300) {
http_response_code(401); exit;
}
// if ($vote['token'] === 'trg-test') { echo 'OK'; exit; } // test button: never reward
// if (already_seen($vote['nonce'])) { echo 'DUPLICATE'; exit; }
// reward_player_by_token($vote['token']);
echo 'OK';
Line by line:
- The secret comes from the environment, not from a file in the web root or the repository.
-
php://inputis the raw body, byte for byte. Sign what was sent, not$_POSTor a re-encodedjson_encode($vote): re-encoding can change spacing or key order and break the signature. -
hash_equalscompares the two strings in constant time. A plain===can stop at the first different byte, and in theory that timing leaks how much of a forged signature was right. - The 300-second window throws away anything sent more than 5 minutes from the game's clock.
- Only then does the game look up the player and pay.
The same in Node.js with Express:
const crypto = require('crypto');
app.post('/vote-callback', express.raw({ type: 'application/json' }), (req, res) => {
const expect = 'sha256=' + crypto.createHmac('sha256', process.env.TRG_SECRET).update(req.body).digest('hex');
const sig = req.get('X-Signature') || '';
const vote = JSON.parse(req.body);
const ok = sig.length === expect.length && crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expect));
if (!ok || Math.abs(Date.now() / 1000 - vote.sent_at) > 300) return res.sendStatus(401);
if (vote.token === 'trg-test') return res.send('OK'); // test button: never reward
// reward the player behind vote.token, once per nonce
res.send('OK');
});
Two Node details matter. express.raw keeps the body as a Buffer, so the HMAC runs over the exact bytes; express.json() would parse it first and leave nothing raw to sign. And crypto.timingSafeEqual throws if the two buffers differ in length, which is why the length check comes first.
Gotchas
-
Compare safely.
hash_equalsin PHP,timingSafeEqualin Node. Never==. -
Store the nonce (or
vote_id) before you pay, ideally with a unique index, so two retries arriving at once can't both win. ReplyDUPLICATEwith a 2xx so the retries stop. -
Never reward the test token. The dashboard's test button sends the token
trg-test, so a developer can check their endpoint. Answer it, but don't pay anyone for it. - Reply 2xx for anything you have handled, including refusals you don't want retried. A 5xx tells us to try again later.
- Keep rewards modest. If a vote is worth more than an hour of play, players start voting for the reward rather than the game, and rankings stop meaning anything. Our rules also say rewards must never be the only reason to vote: no forcing players to vote to keep playing.
Wrapping up
None of this is new cryptography: it is the same pattern payment providers use for webhooks. But game top-lists went years without it, and a signed callback takes about fifteen lines to check.
We built this for TopRankinGames, a free player-voted top-list where money can't buy rank. If you run an online game, you can list it free at toprankingames.com, and the full callback docs are on the developers page.
Top comments (0)