Firebase security rules
Preface
Securebase is a tool to evaluate your website or Android app for Firestore database vulnerabilities. Our analysis doesn’t mutate your database, and it can detect collections with fully open read and delete access.
Introduction
Firestore is a great NoSQL cloud database for those looking not to delve into the world of databases, hosting them yourselves, and/or maintaining it. It's pay-as-you-go priced, which means you will only pay for what you use, with no minimum fees of any kind, and it can scale as the demand grows.
This guide is for beginners and pros alike so that they can understand how security rules can help them protect their database from malicious actors and unauthorized access among many things, and how to not shoot yourself in the foot.
Get started
As one wise man once said - With great power comes great responsibility. Having the ability to manage a database from your frontend and the ability to scale are powerful advantages, and with that comes the great responsibility to write good and effective rules to protect your data and wallet.
Why Do We Need Rules?
Firestore operates differently in terms of how someone accesses the data. Unlike, say, a PostgreSQL database that may be sitting behind a firewall with a strong password somewhere, and your backend connects to it, you have a different story with Firestore. Your database is not hidden away from the user in the sense that it's accessible without much effort using client libraries, raw HTTP/gRPC calls. This makes it a prime target for any malicious actor with access to the internet.
Thus, the need for security rules, so you can enforce authentication, authorization, and data validation for the data coming in and going out of your database.
Security rules are evaluated for each and every request to your Firestore, and only if the subject rule allows, then the request can succeed in the operation.
According to the Official Documentation
Security rules provide access control and data validation in a simple yet expressive format. To build user-based and role-based access systems that keep your users' data safe, you need to use Firebase Authentication with Cloud Firestore Security Rules.
For the purpose of this whole blog post, I'll be using security rules version 2.
Start from start
Writing security rules and modeling your data should go hand in hand. To effectively enforce rules, this is a must-have requirement.
Basic Rules Structure: Overview
Here's what a Firestore security rule looks like and the basic syntax:
rules_version = '2';
service cloud.firestore {
match /databases/{database}/documents {
match /<some_path>/ {
allow read, write: if <some_condition>;
}
}
}
At the very basic level, your rules start with the declaration of the service the rules are intended for. In this case, it's cloud.firestore
. In the case of cloud storage, it would be firebase.storage
.
This is followed by match /databases/{database}/documents
, which matches documents of any database you may have. Currently, there's only one database called default
, but the Firebase team is keeping it future-proof.
Inside the match /databases/{database}/documents
block is where the action happens; this is where you write your rules.
match /<some_path>/
The match statement is followed by /<some_path>/
, which can be used to match different kinds of hierarchies.
You can match either a path to a document directly, or match a collection, or match a collection and any documents/collections underneath it, or a collection group.
Match a Single Document
To match a single document, you would write the full path to the document -
match /users/user123 {
...
}
For any rules written inside this block of code, they would apply only and only to the document with the path /users/user123
.
Match a Collection
To match a collection, you would write the path to the collection with a placeholder value inside {}
-
match /users/{userId} {
...
}
The placeholder value inside {}
can be anything; it just holds the ID of the document that is being matched inside the block of code.
If you have the above security rule in place, and try to get a document (say user234
) from the users
collection, the userId
value would be user234
, i.e., the ID of the document that is being matched.
Match a Collection (Wildcard)
To match a collection with a wildcard expression, you would add ==*
-
match /users/{userId==*} {
...
}
The expression above would evaluate access for any documents under the users collection, including documents of subcollections. The userId
this time would not hold the document ID necessarily; instead, it would hold the whole path to whichever document is being evaluated.
Match a Collection Group
Here's the syntax for matching collection groups -
match /{path=**}/posts/{post} {
...
}
In the example above, the rules would apply to any subcollection called posts
.
Conditions and Expressions
Now, let's discuss what goes inside a match statement: the allow
expression followed by the operation name, i.e., allow read
, allow write
, or allow read, write
, followed by the rules themselves inside {}
.
The read
rule is an alias for two different rules:
list
- The list rule is used to set access control for queries.get
- The get rule is used to set access control for accessing a single document.
The write
rule is an alias for:
create
- Determines access control for creating documents.update
- Determines access control for updating documents.delete
- Determines access control for deleting documents.
For every allow statement, it is followed by a condition:
- If the condition is
true
, for example,allow read, write: if true;
, it will allow anyone to query, get, create, update, delete. - If the condition is
false
, then it will deny all requests, but privileged access isn't hampered by this. Security rules don't apply to the Admin SDK.
The Importance of Firebase Auth
When you're using/accessing Firestore using client libraries, it's absolutely necessary that you use Firebase Auth. These two go hand in hand. Aside from being free, Firebase Auth is the only authentication/authorization mechanism that has support in Firestore rules.
Without Firebase Auth, enforcing Firestore rules is almost impossible.
You can use variables at your disposal, such as request.token
, to get info on many authentication/authorization related details, which simply won't be possible otherwise.
There's more to conditions. We'll explore them later in the Local Development and Examples section.
More rules
In the scope of security rules, you have access to two global objects: request
and response
, which have properties, but the most notable are:
request.auth
- contains authentication info about the user making the request (if available).request.resource
- represents the new state of the document as it will be after the current request is completed. It includes changes made by the request.resource
- represents the current state of the document in the database before the request is applied.
Preventing Unauthorized Access
You should keep security rules in mind when preparing your data models. A very common pattern used is users/{userId}
, and this pattern can be useful if you leverage Firebase Auth.
You can model your data so that the document ID of your users collection matches the UID of the user in Firebase Auth.
This way, you can restrict the read and write permissions of a user to the document whose ID equals their UID. They won't be able to read or write someone else's data.
Here's how you would implement it:
match /users/{userId} {
allow read, write: if request.auth.uid == userId;
}
Another technique for documents where having the document ID equal to the user ID is not possible is to have a field, let's say ownerUid
, and put the UID of the user who owns this document.
match /orders/{orderId} {
allow read, write: if resource.data["ownerUid"] == request.auth.uid;
}
The above check would prevent anyone but the user with a UID equal to ownerUid
from reading or writing the document. This rule would lead to an error in case of a create operation because we are checking against a property already existing in the document. But in case of create, the document doesn't already exist. In those cases, you can do:
match /orders/{orderId} {
allow read, update, delete: if resource.data["ownerUid"] == request.auth.uid;
allow create: if request.resource.data["ownerUid"] == request.auth.uid;
}
Now, the condition accommodates create operations but is still prone to errors since the incoming data or even the document may sometimes not contain ownerUid
. For that, you can:
match /orders/{orderId} {
allow read, update, delete: if (("ownerUid" in resource.data) ? resource.data["ownerUid"] : null) == request.auth.uid;
allow create: if (("ownerUid" in request.resource.data) ? request.resource.data["ownerUid"] : null) == request.auth.uid;
}
The above rule checks if a field called ownerUid
exists, only then will it proceed with other checks. This is good as it prevents errors. However, it has become verbose, and there's a way to reduce the lines of code and organize it better!
Meet Functions
The concept of functions in security rules is exactly the same as everywhere else. It helps reuse the same logic again and again. You use the function
keyword to define a function.
In the above code, we can decouple the logic into 2 distinct and reusable functions that can be used elsewhere -
function _getValue(key, data) {
return key in data ? data[key] : null;
}
function getResourceValue(key, from) {
return from != null && _getValue(key, from);
}
match /orders/{orderId} {
allow read, update, delete: if getResourceValue("ownerUid", resource.data) == request.auth.uid;
allow create: if getResourceValue("ownerUid", request.resource.data) == request.auth.uid;
}
Using request.auth.token
request.auth.token
contains the decoded token for the requester. It may contain fields such as email
, emailVerified
, phone_number
from Firebase Auth, as well as custom claims.
You can leverage this to your advantage if there's a need to restrict access to documents based on fields inside request.auth.token
.
For example, say you have a collection where you're not using ownerUid
but instead using ownerEmail
. Then you may use -
function getTokenField(key) {
return request.auth != null && _getValue(key, request.auth.token);
}
match /orders/{orderId} {
allow read, update, delete: if getResourceValue("ownerEmail", resource.data) == getTokenField("email");
allow create: if getResourceValue("ownerEmail", request.resource.data) == getTokenField("email");
}
This method demonstrates how to dynamically access token fields and document fields to enforce security rules based on user attributes, enhancing security and flexibility in Firestore document access.
Security Rules Aren't Filters
As I explain how you can use a field in the resource data to evaluate rules, let me also address this infamous issue people encounter: "Security rules aren't filters."
What it essentially means is that if you try to query the orders
collection, you can't get anything back without a where
clause. Don't expect security rules to evaluate each and every document for you, check if the ownerEmail
field equals the field in the requester's token, and then return you the data after filtration.
That's not what it's intended to do.
Instead, if your rules are tuned like the rule before, you can put a where
clause that checks the ownerEmail
field equals the requester's email
. Then you won't get this infamous error.
Custom Claims
Custom claims is an advanced topic. Custom claims can be used to define access control levels. For example, you can set a field called role
with the value admin
into the custom claim itself, and after you mint the token and the user signs in using signInWithCustomToken
, subsequent requests they make to Firestore would contain request.auth.token.role
field, whose value would be admin
.
You can leverage it to set access control at the auth level.
match /companies/{document=**} {
allow read, write: if request.auth != null && request.auth.token.role == "admin";
}
A Terrible Security Rule
A common practice I see here and there is to use the condition request.auth != null
.
This is a terrible security rule and shouldn't be used anywhere. It might stop the reminder emails from Firebase but doesn't make your database secure.
Understand this rule: request.auth != null
means the auth
object should not be null, implying any authenticated user can perform the operation.
Now, if you use this rule for read, write
, it will allow any authenticated user to read and write any document in your database. That's why it's a very bad rule and should not be used in production.
Local Development
Setting Up the Environment
Assumption: You have a Firebase project set up with Firebase Auth and Firestore, and have a recent version of Node.js & npm installed on your computer.
Step 1 - Install and Configure firebase-tools
The firebase-tools
package is the CLI tool to interact with Firebase's services. You can use it to deploy functions, update rules, run emulators, deploy a website to Firebase hosting, among other things.
Run npm install -g firebase-tools
to install the package/CLI.
You need to first log in to the Firebase CLI using the firebase login
command.
Step 2 - Initialize Project Locally
Create or go to an empty directory where you want to initialize the project. Run firebase init firestore
.
After that, you should have a firestore.rules
file. This is where you will write rules locally. Then, deploy the rules using firebase deploy --only firestore
.