DEV Community

Cool Monkey Studios
Cool Monkey Studios

Posted on

Rewarding players for votes without trusting the client: signed vote callbacks for a game top-list

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
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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';
Enter fullscreen mode Exit fullscreen mode

Line by line:

  • The secret comes from the environment, not from a file in the web root or the repository.
  • php://input is the raw body, byte for byte. Sign what was sent, not $_POST or a re-encoded json_encode($vote): re-encoding can change spacing or key order and break the signature.
  • hash_equals compares 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');
});
Enter fullscreen mode Exit fullscreen mode

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_equals in PHP, timingSafeEqual in 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. Reply DUPLICATE with 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)