AGS Logo AGS Logo

Firebase Storage Rules

Several rows of small mail boxes with key-based locks that might be found in a postal office or apartment

Photo by Tim Evans on Unsplash

One important way to secure your Firebase projects is through careful use of rules. Doing this well allows you to use the Client SDKs, such as the JavaScript API for web apps, to securely retrieve data and files from your client apps without building your own custom API layer, saving time & money and increasing overall performance.

Cloud Storage for Firebase has historically been the hardest thing to secure through the use of rules, but changes released in September 2022 have made it possible to build powerful rules that utilize your existing Firestore data. This new feature allows you to create security rules for your storage bucket that use your existing ACL or RBAC authorization data such as I showed in my earlier blog article, Implementing Authorization Models in Firebase.

If you attempt to write storage rules against your Firestore data, however, you'll probably notice that the available documentation is somewhat inconsistent and incomplete. Let's start with an example with a small storage bucket rule. This rule checks that the user is logged in, trying to access their own files, and has a user record in the Firestore database:

match /users/{uid}/{filename=**} {
  allow read: if firestore.exists(/databases/(default)/documents/users/$(uid))
                 && request.auth.uid == uid;
}
User record exists

Note that we're able to use the {uid} from the match pattern as a variable. Also, we're using the built-in request variable which provides access to the requesting user's uid for validation. We're also using firestore.exists() to check that a value exists in Firestore. Sometimes you don't just need to check that a value exists, but need to test against the value. The following example expands on the previous one by making sure the account is marked active:

match /users/{uid}/{filename=**} {
  allow read: if firestore.get(/databases/(default)/documents/users/$(uid)).data.active
                 && request.auth.uid == uid;
}
Check property on user record

The above example, "Check property on user record" will allow a read if active is true and reject for false, but it will also reject by throwing an error if active doesn't exist on the record or if there is no record for the given uid. This is fine from a security perspective, but throwing errors to reject access can make rules extremely hard to troubleshoot and can actually break complex rule sets. This is because Firebase rules use a first-to-true model where the default access is false (no access) but the first rule to generate a true (allow) value grants permission to the requested resource. Multiple false responses will still allow a later true response to be successful, but a single error response will short-circuit the permissions checks and disallow access regardless of a later rule that would have returned true. There are a couple ways we can modify our example to get around the possibility of an error:

match /users/{uid}/{filename=**} {
  // Option 1
  allow read: if firestore.exists(/databases/(default)/documents/users/$(uid))
                 && firestore.get(/databases/(default)/documents/users/$(uid)).data.get('active', false)
                 && request.auth.uid == uid;

  // Option 2
  allow read: if firestore.exists(/databases/(default)/documents/users/$(uid))
                 && (
                   'active' in firestore.get(/databases/(default)/documents/users/$(uid)).data                 
                   ? firestore.get(/databases/(default)/documents/users/$(uid)).data.active
                   : false
                 )
                 && request.auth.uid == uid;
}
Defensive rules

The two defensive rules presented in the above example are effectively equivalent with the first option being much more straightforward, but it's important to understand all of the options here to build more complex rules in the future. First, rules support the ternary operator ?: such as condition ? if-true : if-false. Second, because data is a Map type we can use the in operator to determine if a specific property is contained within the map. Alternatively, we can use the get() method of the map to retrieve a property with a default value in case it doesn't exist.

One problem with both of these examples is that they use one or more firestore.get() operations alongside a firestore.exists() operation, both of which have to connect to the database. It's not clear if this has a performance impact but we should reasonably expect it to. More critically, however, these reads count against your project's Firestore quota and billing. Additionally, there is a strict limit of 10 exists() and get() calls against a single record request (or 20 for multi-record requests), so we'll need to write rules in such as way that we can minimize how many calls are required.

The next features we'll need to implement sophisticated storage rules are Functions and Variables. Let's take a look at how the previous rule could have been written using these features:

function isOwnResource(uid) {
  return request.auth.uid == uid;
}

function getUserData(uid) {
  let userPath = /databases/(default)/documents/users/$(uid);
  let user = firestore.get(userPath); //  Returns null if it doesn't exist
  return user != null ? user.data : { 'active': false };
}

match /users/{uid}/{filename=**} {
  allow read: if getUserData(uid).get('active', false) && isOwnResource(uid);
}
Functions and variables

By using functions we've significantly increase our read rule's legibility. By using variables we've made the functions easy to read by separating the database path from the firestore.get() call, and have incorporated protection from missing data without an extra call to firestore.exists(). Note that an interesting (and largely undocumented) feature of rules is that we can construct a JSON object which will behave as a Map within the rules. This can be assigned to a variable, used as a default value, and returned from a function.

Data Validation

Now we're ready to take a look at adding data validation to our rules. Let's start by restricting size limits for newly created f iles.

  allow create: if request.resource.size < 10 * 1024 * 1024;
Max size validation

This compares the file size of the new file against a limit of 10 MiB. We can also restrict content based upon type:

  // Allow Images
  allow create: if request.resource.contentType.matches('image/.*');
  
  // Allow Videos
  allow create: if request.resource.contentType.matches('video/.*');
  
  // Allow specific document types
  allow create: if request.resource.contentType in [
                     'application/pdf', // .pdf
                     'application/vnd.openxmlformats-officedocument.wordprocessingml.document', // .docx
                     'application/epub+zip' // .epub
                   ];
Content type validation

The first rule uses a pattern to allow all types of image files while the second similarly allows all video files. The third rule uses the in operator on the List to check against a specific set of document types (PDF, Microsoft DOCX, and ePub).

File type checking can be particularly useful in cases where you're going to be doing additional processing on newly created files and want to avoid compatibility issues. For example, you might be creating thumbnails from images, extracting still images from videos, or indexing the contents of text files.

Advanced Access Example

Finally, let's take a look at a sophisticated real-world example of storage rules written against RBAC (Role-based access control) data stored in Firestore complete with data validation. For these examples we'll assume the following data structures:

/access/{uid}:

{
  roleId:           string
  totalStorageUsed: number
}

/roles/{roleId}/features/storage

{
  create:          boolean
  read:            boolean
  update:          boolean
  delete:          boolean
  maxFileSize:     number
  maxTotalStorage: number
}

Data types

We'll use this data both to define the permissions to test against but also to enforce application-defined quotas that can be configured by user/role instead of hard-coded into the rules. Let's start by defining some helper function for validation:

function isOwnResource(uid) {
  return request.auth.uid == uid;
}

function isImage() {
  return request.resource.contentType.matches('image/.*');
}

function sizeInMiB(size) {
  return size * 1024 * 1024;
}
Validation functions

We're going to use these to ensure users can only access files they own and to ensure that only images can be added. Next lets look at the functions that will access Firestore data:

function getAccessData(uid) {
  let accessPath = /databases/(default)/documents/access/$(uid);
  let access = firestore.get(accessPath);
  return access != null ? access.data : { 'roleId': null, 'totalStorageUsed': 0 };
}

function getRoleStoragePermissions(roleId) {
  let featurePath = roleId != null ? /databases/(default)/documents/roles/$(roleId)/features/storage : null;
  let feature = featurePath != null ? firestore.get(featurePath) : null;
  return feature != null ? feature.data : {
    'create': false, 'read': false, 'update': false, 'delete': false,
    'maxFileSize': 0, 'maxTotalStorage': 0
  };
}
Data access functions

The first function can retrieve access data while the second retrieves the storage permissions for a given role. Note how we are explicitly providing null safety. We can put these together like this:

function hasPermission(uid, permission) {
  let roleId = getAccessData(uid).roleId;
  let permissions = getRoleStoragePermissions(roleId);
  return permissions[permission];
}

function canWriteFile(uid, permission) {
  let accessData = getAccessData(uid);
  let permissions = getRoleStoragePermissions(accessData.roleId);
  let totalStorageUsed = accessData.totalStorageUsed != null ? accessData.totalStorageUsed : 0;
  let maxTotalStorage = permissions.maxTotalStorage != null ? sizeInMib(permissions.maxTotalStorage) : 0;
  let fileSize = request.resource.size;
  let maxFileSize = permissions.maxFileSize != null ? sizeInMib(permissions.maxFileSize) : 0;
  // Have permission, file is within size limits, file won't put us over storage quota
  return permissions[permission]
         && fileSize < maxFileSize
         && totalStorageUsed + fileSize < maxTotalStorage;
}

match /users/{uid}/images/{filename=**} {
  allow create: if isOwnResource(uid) && isImage() && canWriteFile(uid, 'create');
  allow read: if isOwnResource(uid) && hasPermission(uid, 'read');
  allow update: if isOwnResource(uid) && isImage() && canWriteFile(uid, 'update');
  allow delete: if isOwnResource(uid) && hasPermission(uid, 'read');
}
Combining into usable rules

Note that the canWriteFile() function has duplicated everything in the hasPermission() function along with additional checks for file size. This violates the DRY principle we've come to expect from functions but is absolutely essential to the way rules quotas work as it allows us to use the minimum number of 2 firestore.get() calls for each rule. You may have noticed that our data structure also holds a key to being able to minimize the number of Firestore calls from our rules by keeping the data we need in only two locations.

Another important aspect of quota management is the sequence of tests within a single rule. When you look at the create rule of isOwnResource(uid) && isImage() && canWriteFile(uid, 'create') you'll note that we're doing the non-firestore tests first so that if either isOwnResource() or isImage() checks fail we'll never call Firestore and thus never consume quota.

Final Listing

Putting together everything we've covered results in the following storage rules:

rules_version = '2';
service firebase.storage {
  match /b/{bucket}/o {
    function isOwnResource(uid) {
      return request.auth.uid == uid;
    }

    function isImage() {
      return request.resource.contentType.matches('image/.*');
    }

    function sizeInMiB(size) {
      return size * 1024 * 1024;
    }

    function getAccessData(uid) {
      let accessPath = /databases/(default)/documents/access/$(uid);
      let access = firestore.get(accessPath);
      return access != null ? access.data : { 'roleId': null, 'totalStorageUsed': 0 };
    }

    function getRoleStoragePermissions(roleId) {
      let featurePath = roleId != null ? /databases/(default)/documents/roles/$(roleId)/features/storage : null;
      let feature = featurePath != null ? firestore.get(featurePath) : null;
      return feature != null ? feature.data : {
        'create': false, 'read': false, 'update': false, 'delete': false,
        'maxFileSize': 0, 'maxTotalStorage': 0
      };
    }

    function hasPermission(uid, permission) {
      let roleId = getAccessData(uid).roleId;
      let permissions = getRoleStoragePermissions(roleId);
      return permissions[permission];
    }

    function canWriteFile(uid, permission) {
      let accessData = getAccessData(uid);
      let permissions = getRoleStoragePermissions(accessData.roleId);
      let totalStorageUsed = accessData.totalStorageUsed != null ? accessData.totalStorageUsed : 0;
      let maxTotalStorage = permissions.maxTotalStorage != null ? sizeInMib(permissions.maxTotalStorage) : 0;
      let fileSize = request.resource.size;
      let maxFileSize = permissions.maxFileSize != null ? sizeInMib(permissions.maxFileSize) : 0;
      // Have permission, file is within size limits, file won't put us over storage quota
      return permissions[permission]
             && fileSize < maxFileSize
             && totalStorageUsed + fileSize < maxTotalStorage;
    }

    match /users/{uid}/images/{filename=**} {
      allow read:   if isOwnResource(uid) && hasPermission(uid, 'read');
      allow create: if isOwnResource(uid) && isImage() && canWriteFile(uid, 'create');
      allow update: if isOwnResource(uid) && isImage() && canWriteFile(uid, 'update');
      allow delete: if isOwnResource(uid) && hasPermission(uid, 'delete');
    }
  }
}
Complete security and validation rules

Bonus Cloud Functions

You may have noticed that our rules are looking at the totalStorageUsed property under /access/{uid} and wondered where this value comes from. The answer is that we need to have Cloud Functions keep track of our quota usage. This topic could use it's very own blog post (let me know if you want one) but in the interest of giving you a complete solution here's the TypeScript code for the functions used to keep track of the storage usage.

import { onObjectDeleted, onObjectFinalized } from 'firebase-functions/v2/storage'
import { FieldValue, getFirestore } from 'firebase-admin/firestore'

export const bucket_onCreate_trackFileSize = onObjectFinalized((event) => {
  const filePath = event.data.name
  const fileSize = event.data.size
  const uid = filePath.match(/users\/([^/]+)\/images\//)[1]
  
  return getFirestore().collection('access').doc(uid).set({
    totalStorageUsed: FieldValue.increment(Number(fileSize))
  }, { merge: true })
})

export const bucket_onDelete_trackFileSize = onObjectDeleted((event) => {
  const filePath = event.data.name
  const fileSize = event.data.size
  const uid = filePath.match(/users\/([^/]+)\/images\//)[1]
  
  return getFirestore().collection('access').doc(uid).set({
    totalStorageUsed: FieldValue.increment(Number(-fileSize))
  }, { merge: true })
})
File stats cloud function

These functions will run anytime a file is created or deleted and increment or decrement the totalStorageUsed accordingly.

There are few big considerations in a production scenario:

  1. If you already have files and want to introduce quotas you'll need a way to "catch up" and set this value correctly for the first time.
  2. If your application is extremely high-volume (or experiences high traffic spikes) you may have issues with this approach and need something like the Distributed Counter
  3. This example is reading/writing the total size in bytes, but this could cause numerical overflow for very large files and you may want to store the size in MB/MiB instead. Doing so may cause data corruption over time due to rounding errors and you would want to have a scheduled job auditing the values on a regular basis.

Additional Security Considerations

FIRST TIME NOTE: Before using Firestore functions from your storage rules for the first time, you'll need to enable special permissions. You can do this from the Storage tab in your project's Firebase Console. When you visit the console page for the first time after deploying Storage rules that contain firestore.get() or firestore.exists() you'll be prompted to enable the necessary permissions for your project.

Storage rules exist specifically to enforce reading/writing files using the Firebase SDK or API. This is only for storage bucket operations and does not include enforcement of read restrictions based upon file URL using the downloadURL feature. It's absolutely possible to have a read rule that disallows access while still making the file publicly available via its URL. This is a topic I'll write about in the future but please feel free to reach out directly if this is something you would like me to prioritize. All of the code found in this blog post is available on GitHub.

Specialties

Overview of our specialties including: accessibility, angular, CSS, design, and Firebase

References

License: CC BY-NC-ND 4.0 (Creative Commons)