7 tips on Firebase security rules and the Admin SDK

Firebase is a great platform for developing rich mobile and web applications by leveraging the power and scale of the Google Cloud Platform. Firebase provides a wide range of composable backend services and SDKs that help developers overcome numerous challenges in app development including user authentication, data storage, notification delivery, and more.

In addition to the services and the SDKs, Firebase also offers security rules — a powerful mechanism that helps enforce the security and logical correctness of your apps. The backend services use security rules to authorize and validate the requests made by client apps, and make sure they adhere to the policies that app developers have put in place. Today, you can use security rules to govern how your users interact with the Firebase Realtime Database, Cloud Storage, and Cloud Firestore. Rules in these Firebase products help you achieve two critical goals:

  1. Enforcing authorization: Rules can make sure that your users whose identity is verified by Firebase Authentication can only do things they are allowed to.
  2. Enforcing data validations and business logic: Both Realtime Database and Cloud Firestore are NoSQL, schemaless databases. But in most applications you would want certain integrity constraints imposed on your data.

If you’re using any Firebase product mentioned above, rules are essential. But you may have questions about how they fit into your application architecture. In order to shed some light on this subject, we’d like to share a few tips related to security rules and the Admin SDK.

Tips

1. Admin SDK bypasses security rules

As you explore security rules in depth, you will eventually discover that requests from the Firebase Admin SDK are not gated by rules. The Admin SDK is initialized with a service account, which gives the SDK full access to your data. In Firebase Realtime Database, you can scope the Admin SDK’s privileges to a user ID, and enforce rules as usual. But other products, most notably Google Cloud Firestore, don’t support this feature yet. You should be mindful about that when implementing server-side data access operations.

Because of the elevated privileges of service accounts and the Admin SDK, you should also make sure that they only get deployed in environments that you trust with administrative control of your project. Typical environments include servers controlled by the developers, and managed cloud environments like Google Cloud Functions and App Engine. On the other hand, end-user devices and web browsers where the application code is open for modification are inherently untrusted, and the Admin SDK should never be deployed in them.

2. Make certain data read-only

Many applications have data that is critical to the operation of the app, but should never be modified by the users. Consider a forum app that promotes user engagement by awarding its participants points (think StackOverflow). Each forum post needs to be scored in near real-time so the users can track their progress, but the users themselves should never be able to change anyone’s points, not even their own.

The simplest way to protect such application-managed data is to specify a rule that prevents all writes to the data from the users.

service cloud.firestore {
  match /databases/{database}/documents {
    function isAuthorized() {
      // Some function that grants users read access.
    }

    match /scores/{uid}/{document} {
     allow write: if false; // Nothing gets past me (except Admin of course).
     allow read: if isAuthorized(); // Grant read access as necessary.
    }
  }
} 

Then you can use the Admin SDK to implement backend services that keep the data up-to-date. For instance, you can implement a serverless function using Cloud Functions for Firebase that automatically executes whenever a user posts something in the forum. This function can determine how many points to award the user, and update the respective entries in the read-only scores collection.

import * as admin from 'firebase-admin';

admin.initializeApp();

export const updateScores = functions.firestore.document('posts/{userId}/{postId}')
  .onCreate((snapshot, context) => {
    const score = calculateScore(snapshot);
    const userId = context.params.userId;
    const doc = admin.firestore().collection('scores').document(userId);
    return admin.firestore().runTransaction((txn) => {
      return txn.get(doc).then((snap) => {
        const current = snap.data().total || 0;
        txn.set(doc, {total: current + score}, {merge: true});
      });
    });
  }); 

Since Cloud Functions is a trusted environment, the backend code can continue to update your data, while keeping users from doing something they are not allowed to.

3. Role-based access control with custom claims

Many applications need to deal with users in different roles. What individual users can do in the app usually depends on their roles. Let’s take a MOOC (Massively Open Online Courses) app as an example, where there are teachers, students and TAs. Teachers and TAs should be able to view and update course content, but students should only be able to view the material.

In a Firebase app, user roles can be managed by setting custom claims on user accounts. This is a privileged operation that can only be performed in a backend environment, typically using the Admin SDK. Custom claims are additional information that we associate with user accounts in Firebase Auth, and this information becomes available in the ID tokens that Firebase issues to users upon sign in. You can inspect these claims via security rules to facilitate role-based access control.

Going back to our example MOOC app, we can use the following backed code to grant a user teacher role.

import * as admin from 'firebase-admin';

admin.initializeApp();

async function grantTeacherRole(userId: string) {
  await admin.auth().setCustomUserClaims(userId, {role: 'teacher'});
} 

Now you can define a rule that only allows teachers and TAs write-access to the courses collection.

service cloud.firestore {
  match /databases/{database}/documents {
    function isTeacher() {
      return request.auth.token.role == "teacher";
    }

    function isTA() {
      return request.auth.token.role == "ta";
    }

    match /courses/{doc} {
      allow write: if isTeacher() || isTA(); // Only teachers and TAs can write.
      allow read: if true; // But anybody can read.
    }
  }
} 

Note that Firebase client SDKs cache ID tokens up to an hour. Therefore changes to a user’s custom claims may take up to an hour to take effect.

Check out this video walkthrough if you want an in-depth guide to building an RBAC system.

4. Temporarily withhold sensitive data from users

Sometimes you want the ability to withhold some data from users until an administrator or a backend service determines it’s time to release the data. For example, consider an automated process that grades tests in a MOOC app. You would want this process to finish grading all the tests before any scores are shared with the students. In this case the grading process should be able to update any Firestore document, and it can be deployed in a trusted backend environment. Therefore you can use the Admin SDK to implement it.

To make sure the intermediate states of data are not visible to users, you can create each document with a visibility attribute set to "private". Then in security rules, restrict access to only those documents whose visibility attribute is set to "public". Here’s what this rule would look like:

service cloud.firestore {
  match /databases/{database}/documents {
    function isPublic() {
      return resource.data.visibility == "public";
    }

    match /grades/{document} {
     allow read: if isPublic(); // Cannot read unless marked as "public".
     allow write: if false; // Nobody except Admin can update the documents.
    }
  }
} 

With the above rules configuration in place, all documents created with the visibility attribute set to "private" are inaccessible to the end-users. When the backend process is ready to release a document to the users, it can use the Admin SDK to change the visibility attribute of the target document to "public".

import * as admin from 'firebase-admin';

admin.initializeApp();

async function gradeTests() {
  // Create a new document and continue to write to it.
  const doc = await createNewDoc();
  await updateGrades(doc);

  // Later, make the document visible when ready.
  await releaseGrades(doc);
}

async function createNewDoc() {
  const doc = admin.firestore().collection('grades').document();
  // Make the new doc hidden by default
  await doc.set({visibility: 'private'});
  return doc;
}

async function releaseGrades(doc) {
  await doc.update({visibility: 'public'});
} 

As soon as the visibility attribute is set to "public" on a document, it will start appearing in matching Firestore queries executed by users.

5. Minimize the rules that apply to a document

It can be tempting to write rules that apply across collections — for example, writing a rule that grants teachers in the MOOC app access to all documents in the database. In such situations, remember that rules will grant access to a document if any match statement in the rules configuration grants access. When multiple rules apply to the same document, it is easy to forget that any rule that allows access, overrides all the other rules that deny access.

service cloud.firestore {
  match /databases/{database}/documents {
    match /reports/{document} {
     // This rule is intended to selectively grant users read-only access to the
     // documents in the 'reports' collection. But the rule below inadvertently
     // grants teachers read-write access to these documents.
     allow read: if isConditionMet();
     allow write: if false;
    }

    match /{document=**} {
     // This rule matches all documents in the database, including the 'reports'
     // collection. In case of teachers, this will override the previous rule.
     allow read, write: if isTeacher();
    }
  }
} 

To avoid accidentally granting users access to protected data in your app, you should try to write rules in a manner so that each document only matches a single rules statement. One way to make that easier is to avoid wildcards that match collections ({document=**}), and only use wildcards that match documents ({document}). If you’re tempted to define overarching rules, consider if the Admin SDK would be a better fit, because as we learned in the Tip #1, requests from the Admin SDK bypasses security rules.

6.Develop administrative tools using the Admin SDK

As your apps grow and evolve over time, you may implement various administrative tools to manage your app’s data. For example, you may want to implement a tool that backs up certain Firestore collections or RTDB paths. Or you are trying to meet specific privacy requirements, and you want to implement a service that deletes user data when the data becomes obsolete or when the users demand it. Such tools typically require unrestricted access to large portions of your database.

At first this may look like a good reason to have a relaxed set of security rules. But you should strive to write the most detailed and restrictive rules that describe the access patterns of your app. Administrative tools should be implemented using the Admin SDK, and deployed in a privileged environment that you control. This way your admin tools can retain full access to all the data, while closely regulating what end-users can do in the app.

7. Implement dynamic access control lists

In some situations you may want to temporarily deny a user access to data. Perhaps the monitoring infrastructure of your forum app has just detected a user posting spam, and you want to prevent that user from posting any more content until you can conduct a thorough investigation of the incident. You can use security rules to implement a simple access control list (ACL) on top of Firestore, and use the Admin SDK to dynamically manage it. You would start by declaring a rule like the following:

service cloud.firestore {
  match /databases/{database}/documents {
    function isBlocklisted() {
      return exists(/databases/$(database)/documents/blocklist/$(request.auth.uid))
    }

    // Collections are closed for reads and writes by default. This match block
    // is included for clarity.
    match /blocklist/{entry} {
      allow read: if false;
      allow write: if false;
    }

    match /posts/{postId} {
      allow write: if !isBlocklisted()
    }
  }
} 

This mentions a Firestore collection named blocklist that no user can read or write. It also uses the exists built-in function to check if a document with a given key exists in the Firestore database. If you haven’t seen this pattern before, built-in functions like exists and get enable us to access Firestore documents from security rules. In this case, if we find a user ID in the blocklist collection, we prevent the corresponding user from writing to the posts collection. Now we can use the Admin SDK to add users to the blocklist collection, and revoke their write-access:

await revokeWriteAccess('bAdAgEnT');

async function revokeWriteAccess(userId) {
  const user = admin.firestore().collection('blocklist').document(userId)
  await user.set({
    reason: 'possible bad agent',
    blocklisted_at: admin.firestore.FieldValue.serverTimestamp(),
  });
} 

Since you have already locked down all access to the blocklist collection, you can rest assured that only the Admin SDK (i.e. our backend code) can make modifications to it. Changes to the ACL take effect immediately. To grant a user access to the data again, simply remove the corresponding document with the user ID from the blocklist collection.

Note that we are also writing the current timestamp to the same document when adding a user to the blocklist. If you want, you can write a rule that references this property to automatically grant blocklisted users access after a cool off period.

service cloud.firestore {
  function isTwoDaysElapsed() {
    return request.time > timestamp.value(get(/databases/$(database)/documents/
         blocklist/$(request.auth.uid)).data.blocklisted_at.seconds*1000) +
         duration.value(2, 'd');
  }

  match /databases/{database}/documents {
    match /posts/{postId} {
        // allow if blocklisted more than 2 days ago
       allow write: if isTwoDaysElapsed();
    }
  }
} 

In conclusion…

Firebase takes a declarative approach to ensuring the security and logical correctness of your apps. By keeping the rules separate from application code, you can easily update your security policies, while keeping the application code simple. As many developers know by experience, code changes are harder to make, and even harder to test and deploy. But with Firebase, you can rapidly iterate on your rules without having to touch the application code at all. Moreover, you can patch any detected security vulnerabilities instantly, without having to go through a long and arduous app rollout.

You can also use rules in conjunction with the Firebase Admin SDK to implement sophisticated use cases that involve server-side code. Admin SDK is not subjected to rules checks, but this extra degree of freedom enables some useful patterns that can be applied to multiple real world applications. You can implement any server-side components using the Admin SDK and deploy them in trusted environments like Google Cloud Functions, while subjecting the client-side apps to stricter constraints.

Read more about Firebase security rules and the Admin SDK in our documentation. If you have used these tools to solve any interesting problems, we’d love to hear about your experience. Happy coding with Firebase!