In-depth guide to Firestore Security Rules

Learn how to secure your Firebase Firestore database with Security Rules

·6 min read
Cover Image for In-depth guide to Firestore Security Rules

Firebase's Firestore is a database that applications can communicate with directly from the client-side. As a result, there are many security considerations that developers need to take into consideration.

To help secure our databases, we can leverage Firestore's Security Rules using a scripting language (CEL), which can be extended using functions, not too differently from Javascript.

For both Cloud Firestore and Cloud Storage, we can use the following syntax:

service <<name>> {
  match <<path>> {
    // Allow the request if the following conditions are true.
    allow <<methods>> : if <<condition>>
  }
}

For example, for writing Cloud Firestore's root path, we'd be using the following rules:

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

Document Paths

The path variable is the path to your Firestore document. If you specify a path to a document, the ID of the document is defined as a variable:

match /organizations/{organizationId} {
    // rules here
}

For nested collections, we can insert additional rules within the same scope:

match /organizations/{organizationId} {
    match /invites/{inviteId} {

    }
}

Methods

Methods will disallow any read and write by default. Writing and reading permissions can be written in batch or granularly.

For example, we can write the following conditions:

match /organizations/{organizationId} {
    allow read: if canRead();
    allow write: if canWrite();
}

In most situations, you want to write granular conditions.

Reading documents can be split as follows:

  • list - when we want to query a collection of documents
  • get - when we want to query a single document

Writing a document, instead, can be split in the following ways:

  • create - when we create a new document
  • update - when we write to an existing document
  • delete - when we delete an existing document
match /organizations/{organizationId} {
    allow list: if canList();
    allow get: if canGet();
    allow create: if canCreate();
    allow update: if canUpdate();
    allow delete: if canDelete();
}

Recursive Wildcards

If you want to apply a specific rule recursively to a deep hierarchy, we can use the following syntax:

match /organizations/{organizationId=**} {
    allow read: if condition();
}

Security Rules are not filters

One important something you should notice is that Firestore's rules are not filters: queries that violate the Security Rules will be rejected.

Debugging Firestore's Security Rules using the Emulator UI

As you may know, the Firebase Emulators offer the ability to debug your Firestore database and the queries/writes against it.

If you aren't sure how to run the emulators, take a look at our guide for setting up the Firebase Emulators for your application.

If you have followed the guide above, you should be able to view the Firestore Emulator UI at http://localhost:4000/firestore.

Firestore Emulator Debugging UI

By clicking one of the rows, we can view and analyze the scope of the request that the Firestore security rules have evaluated.

In certain situations, Firestore can be quirky and not fully documented, so it can be necessary to inspect what data is available to the security rules.

You can inspect the scope of a Firestore request using the sidebar the right-hand side after clicking on a request.

Firestore Emulator Debugging UI Sidebar

Knowing how to debug our security rules is the first but essential step to start developing them effectively.

Evaluating an existing document's data

When the client is executing a write (create, update or delete), we can access the data of the document written passed via the request.

We can use the scoped data request.resource to access the document. We should use the data property to access the data itself.

In the image below, you can see the scope of a request that was used to create a new Firestore document:

Firestore Emulator Resource data

The function below is a quick way to access incoming data being written to a Firestore document:

function incomingData() {
    return request.resource.data;
}

How to use this function? Let's write a security rule that allows us to create a document if the document contains a field with a specific value:

function canCreateOrganization() {
    return incomingData().id != null;
}

service cloud.firestore {
    match /databases/{database}/documents {
        match /organizations/{organizationId} {
          allow create: if canCreateOrganization();
        }
    }
}

In the function above, we're checking that the id property of the document exists.

Evaluating the incoming document's data

When we're evaluating data of documents that are already in the database, we need to use the scope resource.data:

function existingData() {
    return resource.data;
}

For example, let's assume the user wants to read the data from an organization document, and that this document contains a map in which each key is the ID of a member.

Explained: if the current user's ID is a key of the map means the member belongs to the organization.

We can use the rule below:

match /organizations/{organizationId} {
    allow read: if userId() in existingData().members;
}

Checking that the user is authenticated

The following function allows us to check that the user is authenticated.

Remember, this only means that the user is not anonymous, not that they're authorized to perform a specific action.

function userId() {
    return request.auth.uid;
}

function isSignedIn() {
    return userId() != null;
}

Accessing any path in Firestore

The security rule can allow us to access any path in the database by using the correct path.

For example, knowing the user's ID (as it is passed via the request scope), we could access the user record using the get function and the following syntax:

get(/databases/$(database)/documents/users/$(userId));

Notice: this will cost reads. Every time our rule reads another document, it will be billed as an additional read. It's a good practice to write security rules efficiently, but sometimes it's simply impossible not to do so.

Similarly, we can use the exists function to check that a Firestore path exists:

function userExists() {
  return exists(/databases/$(database)/documents/users/$(userId));
}

Retrieve the currently signed-in user record

As we learned above, we can access paths in Firestore if we have the data to build the paths to retrieve them.

Let's assume your application, like MakerKit, has a collection named users where we store the data belonging to each user. Your Firestore's collection name can differ, so you may need to update the snippet below.

It can be very helpful to read your current user's record to access data such as permissions, the organizations they belong to, etc.

The functions below help you access the data regarding the currently selected user:

function getUser(userId) {
    return get(/databases/$(database)/documents/users/$(userId));
}

function getCurrentUser() {
    return getUser(userId());
}

Disallow writing to specific fields

The following function can prevent clients from writing the fields passed in as strings when creating a new document. This can be useful when you want to disallow particular writes from clients.

// use this function to prevent from writing certain fields
// to the document, such as sensitive data (permissions, etc.)
// create: if fieldsNotInCreateAction(["permissions"]);

function fieldsNotInCreateAction(fields) {
  return !(request.resource.data.keys().hasAll(fields));
}

Disallow updates to specific fields

The following function can prevent clients from updating the fields passed in as strings:

function fieldsNotInUpdateAction(fields) {
    return (fieldsNotInCreateAction(fields) && !(resource.data.keys().hasAll(fields)));
}

Strictly typing writes

Firestore can also allow us to check if the fields sent by the client have the correct data type.

The following snippet has been taken straight from the Firstore documentation:

allow create: if request.resource.data.tags is list &&
  request.resource.data.tags[0] is string &&
  request.resource.data.product is map &&
  request.resource.data.product.name is string &&
  request.resource.data.product.quantity is int
  }
}

Of course, the best way to write long and complex logic is to split it into its own function:

function canCreateReview(review) {
    return review.score is int &&
      review.headline is string &&
      review.content is string &&
      review.author_name is string &&
      review.review_date is timestamp
}

// conditions
allow create: if canCreateReview(request.resource.data);

Stay informed with our latest resources for building a SaaS

Subscribe to our newsletter to receive updatesor

Read more about

Cover Image for How to sell code with Lemon Squeezy and Github

How to sell code with Lemon Squeezy and Github

·7 min read
Sell and monetize your code by giving private access to your Github repositories using Lemon Squeezy
Cover Image for Writing clean React

Writing clean React

·9 min read
Learn how to write clean React code using Typescript with this guide.
Cover Image for How to use MeiliSearch with React

How to use MeiliSearch with React

·12 min read
Learn how to use MeiliSearch in your React application with this guide. We will use Meiliseach to add a search engine for our blog posts
Cover Image for Setting environment variables in Remix

Setting environment variables in Remix

·3 min read
Learn how to set environment variables in Remix and how to ensure that they are available in the client-side code.
Cover Image for Programmatic Authentication with Supabase and Cypress

Programmatic Authentication with Supabase and Cypress

·3 min read
Testing code that requires users to be signed in can be tricky. In this post, we show you how to sign in programmatically with Supabase Authentication to improve the speed of your Cypress tests and increase their reliability.
Cover Image for Reset the Supabase Database in Cypress

Reset the Supabase Database in Cypress

·4 min read
Resetting your database during E2E tests is important to prevent flakiness. In this tutorial, we'll show you how to reset the Supabase database in Cypress E2E tests.