3d cartoon hands holding a phone

Unlock full course by purchasing a membership

Lesson 8

Understanding and Implementing Firestore Security Rules

EXTENDED

Understanding and Implementing Firestore Security Rules

So far, we have just been interacting with Firestore using the test security rules. This basically lets us do whatever we want. Notice that we have been adding messages to our database without having to login or authenticate in any way. If we launch this application as is, it means absolutely anybody can read/write the data in our database.

It’s not enough to just make restrictions inside of our application either. Let’s say we didn’t have an interface in the application for adding a message

  • it would still be possible for people to connect to our database and read/write as they please. If the security rules allow it, it can be done. We need to make sure our security rules describe exactly what can and can not be done and by whom.

These rules can be found in the Rules tab which is next to the Data tab inside of your Firestore database (accessed through your project in the Firebase console). But, we will generally be using the firestore.rules file inside of our project to manage our rules and deploy them with:

firebase deploy

The set of rules which we have currently (since we are in test mode) looks like this:

rules_version = '2';

service cloud.firestore {
  match /databases/{database}/documents {

    // This rule allows anyone with your Firestore database reference to view, edit,
    // and delete all data in your Firestore database. It is useful for getting
    // started, but it is configured to expire after 30 days because it
    // leaves your app open to attackers. At that time, all client
    // requests to your Firestore database will be denied.
    //
    // Make sure to write security rules for your app before that time, or else
    // all client requests to your Firestore database will be denied until you Update
    // your rules
    match /{document=**} {
      allow read, write: if request.time < timestamp.date(2023, 11, 25);
    }
  }
}

This setup means that all reads and writes will be allowed to the database, no matter who is making them. If you wanted to change these rules, you would need to make modifications to them here and publish the new rules (again, either through the Firebase console or locally and by running firebase deploy).

NOTE: Notice that a timestamp 30 days from whenever you created the database in test mode is included here. If you need additional development/play time with your database without creating proper security rules you could extend this time to a later date. But do not deploy a production application with these rules. Just add on a few extra days or weeks if you need it — don’t remove the timestamp restriction indefinitely. You really don’t want to forget to change these rules and have your application live.

In this lesson, we are going to go on a bit of a journey from creating basic security rules to the final security rules that our application will require in order to be secured to the level that we need. Along the way, this will allow us to explain various aspects of how Firestore Security Rules work.

We won’t be covering every aspect of Firestore Security Rules, there is just too much to learn, but this should give you a solid grounding for how creating security rules works in general. The exact kinds of rules you need to write will depend on the specifics of the application you are creating.

IMPORTANT: I will always recommend in cases where security is important to your application, that you should only handle it yourself if you have a solid understanding of how to secure your application and data. There are some cases where perhaps you are just building an application for fun, or when you aren’t storing important data and any stolen data or data loss will not matter — in these cases, you might choose to tackle security yourself even if you do not have a strong understanding. However, there are many more situations where security is of critical importance, and a surface level understanding isn’t enough to take on the responsibility yourself.

Despite my warning, please do attempt to learn and have fun with security in the right environment. Even if it may take a while to learn enough to start handling all security concerns yourself, knowing a little bit can provide you with a lot of insight.

Match & Allow

Creating Firestore security rules mostly comes down to these two concepts: match and allow. We will use match to target a specific collection or document, and we will use allow to define an expression to determine how that specific collection/document may be accessed.

When writing our match statements, there is a bit of boilerplate code that mostly won’t change. For example, the default rules for a “locked” database (the opposite of our “test mode” database) look like this:

rules_version = '2';
service cloud.firestore {
  match /databases/{database}/documents {
    match /{document=**} {
      allow read, write: if false;
    }
  }
}

This might look a bit intimidating, but we can mostly forget about all of this:

rules_version = '2';
service cloud.firestore {
  match /databases/{database}/documents {

  }
}

This code just means that we are targeting the firestore service, and the first match statement just means we are targeting the default database in our project — we won’t need to change this. All of the important stuff will be inside of this initial match, e.g:

    match /{document=**} {
      allow read, write: if false;
    }

This particular match uses the {document=**} recursive wildcard to target every document in the database, and in this case, it is blocking all reads and writes. You won’t typically just use a blanket match like this, but rather target specific documents or collections with your match statement, e.g:

rules_version = '2';
service cloud.firestore {
  match /databases/{database}/documents {

    match /messages/{message} {
      allow read;
      allow write: if false;
    }

  }
}

In this case, we are attempting to match the messages collection. We use the {message} wildcard to match against any message in the collection, and then this will also make a message variable available to the allow expression we write (such that we could check the specifics of a document in our rule). You don’t have to use a wildcard, you could also just match a single specific document, but you can’t just match an entire collection by doing this: /messages/. If you want your security rules to apply to an entire collection, you must use a wildcard: /messages/{message}.

Although we are only matching one collection here, and this is all we will need for the application we are building, you can also match other collections or documents (or even sub-collections) and create different rules for those. For example, we might want to do something like this:

rules_version = '2';
service cloud.firestore {
  match /databases/{database}/documents {

    match /messages/{message} {
      allow read;
      allow write: if false;
    }

    match /posts/{post} {
      allow read;
      allow write: if false;

        match /comments/{comment} {
          allow read;
          allow create: if true;
          allow update, delete: if false;
        }

    }

  }
}

This would assume we have another collection called posts and within that, a sub-collection called comments. We can structure our security rules in this way to define access to different collections however we like.

Now let’s briefly touch on some of the specifics of allow before we move on to some examples. We can allow different types of operations based on a condition that we create. This mostly just comes down to writing some kind of if test — you can see some very basic examples of this above, but we will get into it in a little more detail in the next section.

The two generic types of operations are read and write, but these can be broken down into more specific operations:

read

  • get — for single documents
  • list — queries/collection read requests

write

  • create — allows creation of new documents
  • update — allows updates of existing documents
  • delete — allows deleting documents

Depending on the circumstances, you may just want to use the blanket read and write, or you may want to get more specific as we did with the comments example above:

match /comments/{comment} {
  allow read;
  allow create: if true;
  allow update, delete: if false;
}

This would allow any kind of read on the comments, but instead of allowing all kinds of write operations it will specifically allow the creation of new documents. Any attempt to update existing documents, or delete them, would fail.

Creating and Testing Rules

In this section, we are going to focus on creating the rules for the application we are building. Rather than just jumping straight to the end result, we are going to expand upon our security rules step-by-step and learn along the way.

An important thing to keep in mind when writing these rules is that you can test them using the Rules Playground. Typically, I advocate for using the local firestore.rules file to develop your security rules, but one advantage editing through the online Firebase dashboard has is this simulator. You can also test rules locally, but this gets into more advanced territory that we won’t be covering in this course.

You will notice the simulator option in the bottom-left corner of your rules interface. Expanding this will provide you with an interface that you can use to run test read/write operations against your security rules:

Firestore simulator

The basic idea is that you would choose a Simulation type to reflect the kind of operation you are testing against, and then you would specify a Location which would be the specific document or collection you want to test your rules against. Where necessary, you can also toggle the Authenticated option to simulate an authenticated user attempting to access the document, and for write operations you can also build out a test document with specific fields.

For example, if I wanted to test read access to documents in my messages collection I would set the Simulation Type to get and the Location field to messages/{message}. For the simulation, we need to point to a specific document (not the whole collection), so we can just use {message} to refer to a generic document in that collection.

At each step along the way in this lesson, I would recommend testing the rules we create with the simulator. Many of the rules we create initially will intentionally have security flaws, so see if you can simulate scenarios where the rules both pass and fail. You can start by testing our default “test mode” rules against the simulator.

NOTE: Make sure to copy the final rules we arrive at to our local firestore.rules file.

Once you are done messing around with the simulator, let’s write our first rule. Just copy and paste the following into your rules interface:

rules_version = '2';
service cloud.firestore {

  match /databases/{database}/documents {

    match /messages/{message} {
      allow read;
      allow write: if false;
    }

  }

}

Now, rather than creating a blanket rule for our entire database, we are specifically targeting the messages collection (even though this is our only collection in this example, so it doesn’t make much of a difference). We’ve already talked about this particular rule in quite a lot of detail in the previous section, so we won’t spend too much time on it here. The basic idea is that it will allow any reads, but it will deny all writes. See if you can verify this behaviour using the simulator by using both the get and create options.

Now let’s move on to our next rule:

rules_version = '2';
service cloud.firestore {

  match /databases/{database}/documents {

    match /messages/{message} {
      allow read, write: if request.auth != null;
    }

  }

}

This is where things start to get a little more interesting, now we are specifically targeting users who are authenticated. We can access the request object inside of our rules in order to gain information about the specific user who is making the request. By checking request.auth we can tell if this is an authenticated user making the request. In this case, we are allowing any read or write as long as the user is authenticated.

This might sound good, but it still wouldn’t be secure for most scenarios. This means that any user has complete access to the collection as long as they are authenticated. They could create documents, delete their own documents, delete others’ documents, edit others’ documents, and so on. This is getting closer to what we want, but it’s not quite there yet. Let’s try the next rule:

rules_version = '2';
service cloud.firestore {

  match /databases/{database}/documents {

    match /messages/{message} {
      allow read, create: if request.auth != null;
      allow update, delete: if false;
    }

  }

}

This is getting a lot better now. We have made our rules more granular to target more specific operations. We are still allowing all reads for any authenticated user, but we have split up our write into create, update, and delete. We want users to be able to add new chat messages, but we don’t want them to be able to update or delete theirs’ or anybody elses’.

With this set up, any authenticated user can create new documents and read all of the documents. Nobody is allowed to update or delete any documents. These rules are pretty good, but they still aren’t entirely secure, and there are further improvements we can make. Let’s try again:

rules_version = '2';
service cloud.firestore {
  match /databases/{database}/documents {

    match /messages/{message} {
      allow read: if isAuthenticated();
      allow create: if isValidMessage();
      allow update, delete: if false
    }

    match /{document=**}{
      allow read, write: if false
    }

  }

  function isAuthenticated(){
    return request.auth != null;
  }

  function isValidMessage(){
    // email of incoming doc should match the authenticated user
    return request.resource.data.author == request.auth.token.email;
  }

}

This brings us to our final rules, and we’ve done a couple of things here. First of all, we have split out our allow expressions into functions. Rather than repeating the same logic over and over in different rules, we can create functions that do the same thing for us. This allows us to reuse our logic, and can also make things a bit easier to read.

Keep in mind that there are some limitations to these functions, and they can’t just be treated like normal JavaScript functions. For example, they are limited to a single return statement and you can’t add additional logic like if/else statements or loops.

As well as refactoring our rules a little to utilise functions, we have also added some additional logic. Our create operations now not only check for an authenticated user, they also check for a “valid message”.

Although this isn’t something we are really concerning ourselves with in this application, we might want to verify that the author associated with a message matches the email of the user that is currently authenticated with Firebase. The user could mess with the data on the client-side before it is sent to the database, so to combat this we can run this little bit of logic:

return request.resource.data.author == request.auth.token.email;

As well as checking data from the currently authenticated user on request.auth we can also check the incoming document data by accessing request.resource. With this bit of logic, we are just checking that the author on the incoming document matches the email of the current authenticated user (and it should, as long as the user hasn’t messed with the author).

With our rules completed, all you need to do now is hit the Publish button at the top of the rules window and they will soon take effect.

IMPORTANT: I would also recommend copying these rules back into your local firestore.rules file for consistency and also so that the rules apply to the emulator as well. If you have just been adding these rules to your local firestore.rules file and not the Firebase console, make sure to run the firebase deploy command to deploy the rules.

One of the cool things about Firestore security rules is that the security implementation is quite readable/obvious. For example, just by looking at these rules:

    match /messages/{message} {
      allow read: if isLoggedIn();
      allow create: if isLoggedIn() && isValidMessage();
      allow update, delete: if false;
    }

We can get a pretty good idea of what is allowed since it almost translates directly to English:

  • Allow reads if user is logged in
  • Allow creates if user is logged in and the message is valid
  • Never allow updates or deletes

I would like to reiterate again that there is still much more to learn in regard to both Firestore security rules, and security in general. Even the rules we have created here could be improved even further to improve security — perhaps we might want to add some kind of rate limiting to prevent people flooding the database with new messages, or perhaps we might want to enforce the structure of our documents. But this is enough for the example application we are building and it should give you a pretty decent basic understanding to start building off of.