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 documentslist
— queries/collection read requests
write
create
— allows creation of new documentsupdate
— allows updates of existing documentsdelete
— 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:
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.