TutorialsIn-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
Tutorials

Cover Image for Creating a Waitlist with Firebase Auth

Creating a Waitlist with Firebase Auth

·3 min read
Implement a waitlist sign-up with Firebase Auth and allow sign-ins in batches to your SaaS
Cover Image for Using ElasticSearch with Next.js

Using ElasticSearch with Next.js

·8 min read
In this article, we share how to use ElasticSearch with Next.js to index your Firestore documents and make them searchable.
Cover Image for Using Firestore in Firebase Storage Rules

Using Firestore in Firebase Storage Rules

·3 min read
Firebase Storage now allows you to use Firestore queries to in your security rules. Here is all you need to know!
Cover Image for Turn your Next.js application into a PWA

Turn your Next.js application into a PWA

·4 min read
PWA can make your app look native, faster, updatable and offline-ready. In this post, we learn how to make a PWA with a Next.js application.
Cover Image for Email Link Authentication with Firebase and Next.js

Email Link Authentication with Firebase and Next.js

·4 min read
Learn how to add Email Link authentication to your SaaS application with Firebase Auth and Next.js
Cover Image for Walkthrough: Starting a Makerkit project with Firebase and Next.js

Walkthrough: Starting a Makerkit project with Firebase and Next.js

·9 min read
This walkthrough is a summary of the documentation to quickly bootstrapping a SaaS project with Makerkit