securing snake
over the weekend, i launched a live leaderboard for the snake game on my website. it was a fun feature, but i shipped it with some obvious security issues. this is a quick write up on how i went from "immediately hackable" to "reasonably secure" in 24 hours.
the naive implementation
initially, all of the snake game and leaderboard code ran client-side. i had already built a client
component, which contained the interface for the game along with the game and scoring logic. in
order to keep track of the leaderboard, i created a supabase table leaderboard
to store the top
scores:
create table
public.leaderboard (
id bigint generated by default as identity not null,
username text not null,
score integer not null,
submitted_at timestamp with time zone null default now(),
constraint leaderboard_pkey primary key (id),
constraint leaderboard_username_key unique (username)
) tablespace pg_default;
i didn't want users to have to sign in to play, so i set up the rls (row level security) policies on the leaderboard table to allow anyone to read, insert, and update the leaderboard table. i added a simple input that showed up when the game ended that allowed users to enter their username to post their score to the leaderboard. this was pretty simple and allowed me to ship the feature quickly, but was far from robust. i tweeted about the launch early on sunday morning. within minutes, i had leaderboard entries with impossibly high scores showing up. i quickly realized that anyone could inspect the network requests and submit scores via curl:
# send a request to supabase
curl 'https://supabase.co/rest/v1/leaderboard?on_conflict=username' \
-H 'apikey: <supabase-key>' \
-H 'authorization: Bearer <supabase-key>' \
--data-raw '{"username":"hacker","score":999999,"submitted_at":"2024-11-17T17:45:22.381Z"}'
shout out to @pk_iv for being the first.
the first attempt: rpc functions
at this point, it was around 10a and my tweet was starting to gain traction. i wanted to put out a quick fix, but was afraid to ship anything too crazy while the traffic was spiking. my first thought was to use a supabase rpc (remote procedure call) function instead of allowing direct inserts to the database. this would at least allow me to lock down the rls policies, but was still insecure. since the rpc call still ran on the client, anyone could grab it and submit a score using another simple curl command:
# send a request to the rpc function
curl 'https://supabase.co/rest/v1/rpc/submit_score' \
-H 'apikey: <supabase-key>' \
--data-raw '{"username":"hacker","score":999999}'
still too easy. i had more work to do.
adding server-side validation
by the time noon rolled around, i knew i'd need to come up with a better solution. begrudgingly, i moved the score submission logic to the server. this way, the server would validate the score by checking whether the score was possible given the time it took to achieve it. this was a step in the right direction, making it at least more than a simple curl command to game the system. however, it was still relatively easy to manipulate the start time and submit a higher score:
# send a request to the backend with a manipulated game start time
curl 'https://www.basecase.sh/api/submit-score' \
-H 'accept: */*' \
-H 'content-type: application/json' \
-H 'origin: https://www.basecase.sh' \
-H 'referer: https://www.basecase.sh/' \
--data-raw '{"username":"hacker","score":999999,"gameStartTime":1721889984973}'
shout out to @ejcx for being the first to break the new system.
introducing jwt tokens
alas, it was time to get serious. it was almost dinner time and i was determined to ship a more secure solution before the day was over. enter jwts: a jwt (json web token) can be used to securely transmit information between parties as a json object. jwts are signed using a secret key that only the server knows, making them tamper-proof. once a jwt is signed, the data becomes immutable. any attempt to modify the token's contents invalidates the signature and it will be rejected by the server. this was perfect for my use case: i could essentially encode the start time into a token that would be impossible to manipulate.
here's how it works:
- when a game starts, the client requests a token from the server. the server creates a jwt containing the exact start timestamp and signs it with a secret key.
- this token is tied to the start time of the game. the timestamp is encrypted inside the token's payload and can't be changed without breaking the signature.
- when the game ends, the token is submitted along with the score. the server verifies the token's signature and extracts the original start time.
// verify the token and check the score against the game duration
const { startTime } = await verifyToken(gameToken);
const gameDurationSeconds = Math.floor((Date.now() - startTime) / 1000);
// ensure score isn't more than 1 point per second
if (score > gameDurationSeconds) {
return NextResponse.json({
success: false,
message: `${score} in ${gameDurationSeconds} seconds? nice try!`,
});
}
if a user is clever enough to fetch the token, they won't be able to manipulate the start time. that said, they can still wait for enough time to pass and submit a higher score (or write a script to do so). i added a 10-minute expiration to the tokens to prevent players using this technique to submit too high of a score. it was still gameable, but at least i'd make them work for it.
# fetch a game token
TOKEN=$(curl www.basecase.sh/api/start-game -XPOST | jq -r .token)
# wait 5 seconds, then submit a score
curl -d "{\"gameToken\":\"$TOKEN\", \"username\":\"hacker\", \"score\":$SCORE+5}" www.basecase.sh/api/submit-score
exhasted from a day of fending off hackers, i was ready to call it a night. at least i could rest easy knowing that anyone trying to game the system would need to put in real effort. game recognize game.
detecting unrealistic scores
i woke up the next morning to a playful tweet from @mish touting a new high score of 500. i messaged him right away to say well done. at this point, i was more impressed than anything. after nearly 24 hours of working to secure the system, i finally realized: if i couldn't prevent hackers, i could at least have some fun with it. i added a server-side check to reject scores over a certain threshold with a message that pokes a little fun:
// check for suspiciously high scores
if (score > 300) {
return NextResponse.json({
success: false,
message: "impressive work! send a screenshot of your score to @alanaagoyal",
});
}
lessons learned
i learned a few things from this experience:
- client-side code is never secure. anything on the client can and will be inspected, so always validate on the server.
- layered defenses work best. combining techniques (e.g. backend validation, tokens, and manual checks) makes it much harder to hack a system.
- there's always more you can do. the pursuit of securing a system is never done.
there are certainly more ways to improve the system. i could move more of the game logic to the server-side by having the client send each snake movement to the server for validation. this would allow the server to track the full game state, verify collisions, and ensure the score increases legitimately. while this would add some latency, it would make it much harder to fake scores. a more sophisticated approach would be to generate cryptographic proofs during gameplay that verify the legitimacy of a score. the client would send both the final score and its proof to the API endpoint for validation. thanks to @johnphamous for this suggestion. while these techniques would be overkill for the purpose of this game, they're fun to consider.
if you have an idea for how to improve the system, i'd love to hear it and work with you to implement it. the project is fully open-source and available on github.
final thoughts
i often get asked why i spend so much time writing code when it's not my job. the answer is simple: it helps me build empathy for the founders i get to work with. in my case, the stakes were low. when founders face similar security challenges in their startups, they're dealing with real users, real data, and real business impact. experiencing these challenges firsthand, even at a small scale, gives me a better perspective on the relentless pursuit to build great products.
it's also fun to meet people who are curious and creative enough to hack my projects. if you're the type of person who enjoys reverse engineering systems, finding clever workarounds, or just pushing the boundaries of what's possible, i'd love to chat. i'm constantly looking to meet super talented, curious people who can think outside the box. feel free to reach out to me on twitter.