Patterns for security with Firebase: group-based permissions for Cloud Firestore

Doug Stevenson
Firebase Developers
6 min readOct 25, 2019

In my last post, I discussed a couple straightforward ways to use security rules to protect user-owned documents in Cloud Firestore. The key is to put the UID for the user assigned by Firebase Authentication into either the document ID, or a field in the document, then write the rule to compare that string against the UID provided by request.auth.uid when evaluating the rule. But when you need to give access to documents based on a user’s role in your app, it gets more complicated.

Last time, I showed an example of a chat room implemented by a collection called “messages” where anyone could create or update a document with their own UID in a field called “uid”. The rules to allow access looked like this:

match /messages/{id} {
allow read: if request.auth != null;
allow create, update:
if request.resource.data.uid == request.auth.uid;
}

Now let’s say we want to add an “admin” role to the app, where users should be granted the ability to update the contents of existing message documents (in order to correct mistakes to mark them as spam, or as inappropriate). We need some way to store a list of all the users who are in this admin group, and check if the current user is in that list at the time security rules are evaluated. There are a few ways to do this.

Store the group of UIDs in a list field of a document

Since Cloud Firestore security rules have the ability to get() another document and use its field values, we can pretty easily set up a document dedicated to storing a list of UIDs of admin users. Let’s call it “/config/roles”. We’ll use the “admins” field in that document to store an array of UID strings.

Assuming this document is set up, here’s how it can be used in the rule. First, here’s a standalone rule for checking admin access when updating any document in the messages collection:

match /messages/{id} {
allow update: if request.auth.uid in
get(/databases/$(database)/documents/config/roles).data.admins;
}

This rule is doing the following for each update of any message document:

  1. Fetching the /config/roles document
  2. Accessing its “admins” field (which appears as a list type object in security rules)
  3. Checking if the user’s UID is in that list, and if so,
  4. Granting update access to the message document matched by the rule.

This works fine when the list of admins has a predictable, limited size. However, if the list has to scale up in size, it’s possible that a single document might not be able to hold them all, since the maximum size of a document is 1MB. You could then try to shard the admin list across two documents, and use both of them in the rule, but this doesn’t really scale. There is a hard limit of 10 document get operations per rule evaluation. So, what if we need the list of admins to scale massively just like Cloud Firestore itself?

Store the group of UIDs as individual documents

When modeling data in Cloud Firestore, if a list of data can get too big for a single document, you should break up that list into one document per item. There are a couple sub-options here.

The first sub-option is to create dedicated per-user documents for admin access where the presence of the document for the user indicates admin access. Let’s create an “admins” collection for this, using the UID as the document ID.

Note that the per-user document doesn’t contain any fields. It just needs to exist with the correct ID.

Now, to find out if a user is an admin in security rules, all we have to do in the rule is check if that document exists:

match /messages/{id} {
allow update: if
exists(/databases/$(database)/documents/admins/$(request.auth.uid));
}

Here, the full path to the document is being built with the request.auth.uid variable inserted as the document ID in the admins collection.

Alternatively, if there is an existing collection that stores some per-user data, and it’s OK to add another field to that document to indicate group membership, we could use a new boolean field called “isAdmin”. Suppose we have an existing collection called “users” whose document IDs are UIDs, we can just add an isAdmin field to the per-user document:

The rule to check a user’s document for admin access looks like this, which verifies that the user document’s isAdmin field is true:

match /messages/{id} {
allow update: if
get(/databases/$(database)/documents/users/$(request.auth.uid)).data.isAdmin;
}

IMPORTANT: For all of the above cases that rely on the contents of other documents to grant special permission, you should be very careful about allowing users to modify those documents containing the permissions. If your security rules don’t correctly limit access to the documents that assign group roles, you run the risk of users granting themselves admin access simply by creating or updating a document. Bear in mind that if a document doesn’t match any rule, then no user can access it via a client SDK. But you can still use server SDKs to make changes, and the document can still be read by security rules.

It’s worth noting that both exists() and get() in security rules have the same monetary cost as a normal document read. So, just be aware of that when it comes time to estimate billing. But if you’d rather not incur these costs, there is one other option for group permissions.

Store group membership in Firebase Authentication custom claims

Firebase Authentication allows for a small amount of JSON data to be stored for each user account: these are called custom claims. They can be set using the Firebase Admin SDK on a backend you control (including Cloud Functions), then used in security rules to check for access to documents.

Here’s a bit of JavaScript code that uses the Admin SDK for node.js to set a custom claims object for a user:

const uid = "some-uid"
const claims = { isAdmin: true }
await admin.auth().setCustomUserClaims(uid, claims)

If this completes successfully, the “isAdmin” boolean property of the object can be used in security rules, found in request.auth.token like this:

match /messages/{id} {
allow update: if request.auth.token.isAdmin;
}

The limit for custom claims is 1000 bytes of JSON, so if you need a lot of groups, that might cause a problem. In that case, you’re back to using the contents of other documents and paying the cost of document reads. The upside to custom claims is that they can also be used in Realtime Database and Cloud Storage security rules. So if you’re working across products, this is one way to share per-user permissions between each of them.

There’s one other caveat to using custom claims. The API call to set the claims takes effect immediately, however, the new claims don’t get propagated to the client app immediately. If you want that, you’ll have to arrange for it yourself (by having the client call getIdToken after the claims are updated), or the user will have to wait until their current token expires (one hour max).

So, you have a few options to implement group or role permission, each with their pros and cons:

  • UIDs in a document list field is easy to manage, but this doesn’t scale up, and costs a document read to check.
  • UIDs in individual documents scales up, but also costs a document read, and querying that entire group of N members costs N document reads.
  • Custom claims don’t cost anything and can span products, but the payload size is limited and can be difficult to propagate immediately.

More blog posts about Firebase security rules

--

--