Firebase how to secure numeric data from manipulation by users, eg. game score

It will be tricky to guard against invalid values using rules. Since you're giving the user rights to write a value, they can also reverse-engineer your code and write values that you'd rather not see. You can do many things to make the hacker's job more difficult, but there'll always be someone who is able to work around it. That said: there are some easy things you can do to make things for hackers a bit less trivial.

Something you can easily do is record/store enough information about the gameplay so that you can later determine if it is legit.

So for example in a typing game I did, I not only stored the final score for the player, but also each key they pressed and when they pressed it.

https://<my>.firebaseio.com/highscores/game_1_time_15/puf
  keystrokes: "[[747,'e'],[827,'i'],[971,'t'],[1036,'h']...[14880,'e']]"
  score: 61

So at 747ms into the game, I typed an e then i, t, h and so on, until finally after 14.8s I pressed e.

Using these values I can check if the keys pressed indeed lead to a score of 61. I could also replay the game, or do some analysis on it to see if it seems like a real human playing pressing the keys. If the timestamps are 100, 200, 300, etc, you'd be quite suspicious (although I created some bots that type exactly at such intervals).

It's still no guarantee of course, but it's a least a first stumbling block for the ref.child('score').set(10000000) hackers.

I got this idea from John Resig's Deap Leap, but I can't find the page where he describes it.


I have an idea. - since this is a multiplayer game you are going to have multiple players in one particular game. this means each of the players after the game over message is going to update the partial and total score.

In security rules you can check if the opponent has written the partial value regarding the same game. - thats would be read only access. Or you can check if all opponents partial values gives the required total number etc.

Hacker would have to come up with some elaborate plan involving control of multiple accounts and synchronising the attack.

edit: ...and I can see the further question - What about the first player to update? That could be done via intents. So first all the players write an intent to write score where the partial score will be and once there are some values everywhere they will be clear to write the actual score.


Your question is technically how to complete this using security rules, but as it's a bit of an XY problem, and none of the other possibilities have been ruled out, I'll tackle some of them here as well.

I'll be making a great deal of assumptions, since answering this question actually requires a fully specified set of rules that need to be followed and is really a matter of implementing an entire application (increasing a score is a result of the game logic rules, not a simple math problem).

Total the score at the client

Perhaps the simplest answer to this conundrum is to simply not have a total score. Just grab the list of players and total them manually.

When this might be useful:

  • the list of players is hundreds or less
  • the player data is appropriately small (not 500k each)

How to do it:

var ref = new Firebase(URL);
function getTotalScore(gameId, callback) {
   ref.child('app/games/' + gameId + '/players').once('value', function(playerListSnap) {
      var total = 0;
      playerListSnap.forEach(function(playerSnap) {
         var data = playerSnap.val();
         total += data.totalScore || 0;
      });
      callback(gameId, total);
   });
}

Use a privileged worker to update the score

A very sophisticated and also simple approach (because it only requires that the security rules be set to something like ".write": "auth.uid === 'SERVER_PROCESS'") would be to use a server process that simply monitors the games and accumulates the totals. This is probably the simplest solution to get right and the easiest to maintain, but has the downside of requiring another working part.

When this might be useful:

  • you can spin up a Heroku service or deploy a .js file to webscript.io
  • an extra monthly subscription in the $5-$30 range are not a deal-breaker

How to do it:

Obviously, this involves a great deal of application design and there are various levels this has to be accomplished at. Let's focus simply on closing games and tallying the leaderboards, since this is a good example.

Begin by splitting the scoring code out to its own path, such as

/scores_entries/$gameid/$scoreid = < player: ..., score: ... >
/game_scores/$gameid/$playerid = <integer>

Now monitor the games to see when they close:

var rootRef = new Firebase(URL);
var gamesRef = rootRef.child('app/games');
var lbRef = rootRef.child('leaderboards');

gamesRef.on('child_added', watchGame);
gamesRef.child('app/games').on('child_remove', unwatchGame);

function watchGame(snap) {
    snap.ref().child('status').on('value', gameStatusChanged);
}

function unwatchGame(snap) {
    snap.ref().child('status').off('value', gameStatusChanged);
}

function gameStatusChanged(snap) {
    if( snap.val() === 'CLOSED' ) {
        unwatchGame(snap);
        calculateScores(snap.name());
    }
}

function calculateScores(gameId) {
    gamesRef.child(gameId).child('users').once('value', function(snap) {
        var userScores = {};
        snap.forEach(function(ss) {
            var score = ss.val() || 0;
            userScores[ss.name()] = score;
        });
        updateLeaderboards(userScores);
    });
}

function updateLeaderboards(userScores) {
    for(var userId in userScores) {
        var score = userScores[userId];
        lbRef.child(userId).transaction(function(currentValue) {
            return (currentValue||0) + score;
        });
    }
}

Use an audit path and security rules

This will, of course, be the most sophisticated and difficult of the available choices.

When this might be useful:

  • when we refuse to utilize any other strategy involving a server process
  • when dreadfully worried about players cheating
  • when we have lots of extra time to burn

Obviously, I'm biased against this approach. Primarily because it's very difficult to get right and requires a lot of energy that could be replaced with a small monetary investment.

Getting this right requires scrutiny at each individual write request. There are several obvious points to secure (probably more):

  1. Writing any game event that includes a score increment
  2. Writing the total for the game per user
  3. Writing the game's total to the leaderboard
  4. Writing each audit record
  5. Ensuring superfluous games can't be created and modified on the fly just to boost scores

Here are some basic fundamentals to securing each of these points:

  • use an audit trail where users can only add (not update or remove) entries
  • validate that each audit entry has a priority equal to the current timestamp
  • validate that each audit entry contains valid data according to the current game state
  • utilize the audit entries when trying to increment running totals

Let's take, for an example, updating the leaderboard securely. We'll assume the following:

  • the users' score in the game is valid
  • the user has created an audit entry to, say, leaderboard_audit/$userid/$gameid, with a current timestamp as the priority and the score as the value
  • each user record exists in the leaderboard ahead of time
  • only the user may update their own score

So here's our assumed data structure:

/games/$gameid/users/$userid/score
/leaderboard_audit/$userid/$gameid/score
/leaderboard/$userid = { last_game: $gameid, score: <int> }

Here's how our logic works:

  1. game score is set at /games/$gameid/users/$userid/score
  2. an audit record is created at /leaderboard_audit/$userid/games_played/$gameid
  3. the value at /leaderboard_audit/$userid/last_game is updated to match $gameid
  4. the leaderboard is updated by an amount exactly equal to last_game's audit record

And here's the actual rules:

{
    "rules": {
        "leaderboard_audit": {
            "$userid": {
                "$gameid": {
                   // newData.exists() ensures records cannot be deleted
                    ".write": "auth.uid === $userid && newData.exists()",

                    ".validate": "
                        // can only create new records
                        !data.exists()
                        // references a valid game
                        && root.child('games/' + $gameid).exists()
                        // has the correct score as the value
                        && newData.val() === root.child('games/' + $gameid + '/users/' + auth.uid + '/score').val()
                        // has a priority equal to the current timestamp
                        && newData.getPriority() === now
                        // is created after the previous last_game or there isn't a last_game
                        (
                            !root.child('leaderboard/' + auth.uid + '/last_game').exists() || 
                            newData.getPriority() > data.parent().child(root.child('leaderboard/' + auth.uid + '/last_game').val()).getPriority()
                        )

                    "
                }
            }
        },
        "leaderboard": {
            "$userid": {
                ".write": "auth.uid === $userid && newData.exists()",
                ".validate": "newData.hasChildren(['last_game', 'score'])",
                "last_game": {
                    ".validate": "
                        // must match the last_game entry
                        newData.val() === root.child('leaderboard_audit/' + auth.uid + '/last_game').val()
                        // must not be a duplicate
                        newData.val() !== data.val()
                        // must be a game created after the current last_game timestamp
                        (
                            !data.exists() ||
                            root.child('leaderboard_audit/' + auth.uid + '/' + data.val()).getPriority() 
                            < root.child('leaderboard_audit/' + auth.uid + '/' + newData.val()).getPriority()
                        )
                    "
                },
                "score": {
                    ".validate": "
                        // new score is equal to the old score plus the last_game's score
                        newData.val() === data.val() + 
                        root.child('games/' + newData.parent().child('last_game').val() + '/users/' + auth.uid + '/score').val()
                    "
                }
            }
        }
    }
}