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.