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);


Read more about Tutorials

Cover Image for Building an AI Writer SaaS with Next.js and Supabase

Building an AI Writer SaaS with Next.js and Supabase

Ā·57 min read
Learn how to build an AI Writer SaaS with Next.js and Supabase - from writing SEO optimized blog posts to managing subscriptions and billing.
Cover Image for Announcing the Data Loader SDK for Supabase

Announcing the Data Loader SDK for Supabase

Ā·8 min read
We're excited to announce the Data Loader SDK for Supabase. It's a declarative, type-safe set of utilities to load data into your Supabase database that you can use in your Next.js or Remix apps.
Cover Image for Adding AI capabilities to your Next.js SaaS with Supabase and HuggingFace

Adding AI capabilities to your Next.js SaaS with Supabase and HuggingFace

Ā·20 min read
In this tutorial, we will learn how to use add AI capabilities to your SaaS using Supabase Vector, HuggingFace models and Next.js Server Components.
Cover Image for Building an AI-powered Blog with Next.js and WordPress

Building an AI-powered Blog with Next.js and WordPress

Ā·17 min read
Learn how to build a blog with Next.js 13 and WordPress and how to leverage AI to generate content.
Cover Image for Using Supabase Vault to store secrets

Using Supabase Vault to store secrets

Ā·6 min read
Supabase Vault is a Postgres extension that allows you to store secrets in your database. This is a great way to store API keys, tokens, and other sensitive information. In this tutorial, we'll use Supabase Vault to store our API keys
Cover Image for Introduction to Next.js Server Actions

Introduction to Next.js Server Actions

Ā·9 min read
Next.js Server Actions are a new feature introduced in Next.js 13 that allows you to run server code without having to create an API endpoint. In this article, we'll learn how to use them.