Home
> Firestore Security Rules Example Guide
Get updates on future FREE course and blog posts!
Subscribe

Firestore Security Rules Example Guide

13 min read

Jonathan Gamble

jdgamble555 on Sunday, May 5, 2024 (last modified on Sunday, May 5, 2024)

I want you to learn “how to write Firebase Rules” rather than what specific rules you need to write. Once you understand what kinds of operations are available and how to write clean code, you should be able to write reusable functions for any Firebase app.

TL;DR#

Using Firestore Rules can be cumbersome. However, simplifying your rules into reusable functions can make writing complex, secure apps much easier. Remember the Security Rules Limitations, when to use Trigger Functions instead, and how you are charged per document read.

Firestore Rules#

	// firestore.rules
rules_version = '2';

service cloud.firestore {

  match /databases/{database}/documents {
  
    // MATCHES

    match /users/{document} {
    
        allow read, write: if false;
            
    }
  }
}
  • Use version 2 by default
  • Lock your data by default using if false

Permission#

  • allow read - single and multiple documents
    • allow get - a single document with getDoc or onSnapshot
    • allow list - multiple documents with getDocs or onSnapshot
  • allow write - create, update, delete
    • allow create - create a document with addDoc or setDoc
    • allow update - update a document with updateDoc or setDoc using merge
    • allow delete - delete a document with deleteDoc

Resource#

  • resource - document being read or written
    • resource.data - map of document data
    • resource.id - resource document id as string
    • resource['__name__'] or request.path - the document name as path

Request#

  • request - the incoming request context
    • request.auth - request authentication context
      • uid - user ID
      • token - token map
    • request.method - request method
      • get
      • list
      • create
      • update
      • delete
    • request.path - path of affected resource
    • request.query - map of query properties when present
      • limit
      • offset
      • orderBy
    • request.time - time request was received
    • request.resource - new resource value, present on write requests only

Request Resource#

  • request.resource - resource document AFTER write
    • request.resource - map of document data
    • resource.id - resource document id as string
    • resource['__name__'] or request.path - the document name as path

Request token#

  • request.auth.token
    • email
    • email_verified
    • phone_number
    • name
    • sub
    • firebase.identities
    • firebase.sign_in_provider
    • firebase.tenant

Request Patterns#

  • request.path[3] - request collection
  • request.path[4] - request document id or request.resource.id
  • request.path[N] - request sub-collection and sub-collection document ID

There is currently no way to check if a request is for a collection or sub-collection document inside a function. See StackOverflow.

Counters and Timestamp Rules#

Ownership#

  • Create
    • Check for ownership after creation
  • Delete
    • Check for ownership before deletion
  • Update
    • Check for ownership both before and after updating
	// assumes you have a `createdBy` set to uid

function isOwnerBefore() {
  return request.auth.uid == resource.data.createdBy
   || resource == null;
}

function isOwnerAfter() {
  return request.auth.uid == request.resource.data.createdBy 
    || request.resource == null;
}

đź“ť Checking for null before and after makes the function usable in more places with write instead of a separate create and update.

Logged In#

	function isLoggedIn() {
  return request.auth != null;
}

Verifying Keys#

You can verify a document only contains certain fields or that only certain fields can be updated. First, you must get the keys() from the map type.

	function hasAll(data, fields) {
  // data has all fields
  return data.keys().hasAll(fields);
}

function hasOnly(data, fields) {
  // data has only fields
  return data.keys().hasOnly(fields);
}

function isValidUserDocument() {
  let fields = ['title', 'slug', 'minutes', 'content'];
  return hasAll(request.resource.data, fields);
}

đź“ť Arrays are lists in Firebase Rules.

List Methods#

  • concat
  • hasAll
  • hasAny
  • hasOnly
  • join
  • removeAll
  • size
  • toSet - a unique set of lists

Map Methods#

  • diff(map_to_compare)
  • get(key, default_value)
  • keys()
  • size()
  • values()

String Verification Example#

Strings should be verified as well.

	// verify the content is string type and at least 2 characters
&& request.resource.data.content is string
&& request.resource.data.content.size() > 2

String Methods#

  • lower()
  • matches(re)
  • replace(re, sub)
  • size()
  • split(re)
  • toUtf8()
  • trim()
  • upper()

Slug Example#

You could verify a slug, although this might be better handled in a trigger write function.

	// verify slug = a slugify(title)
// should be updated for utf8 characters
request.resource.data.slug == request.resource.data.title
.lower().replace('[^a-z0-9]+', '-').replace('^-|-$','')

Other Types#

Reading is Expensive#

⚠️ Always use the current document from the request.resource, and resource variables. If you need to compare other documents, it will cost one read for each document. You should also consider custom claims instead of reading the user document from request.auth.token. These are all free, as you are already reading the document outside Firestore Rules.

Reading Firestore External Documents#

You can compare other documents with four functions when it can't be avoided.

  • get(path) - document before write
  • getAfter(path) - document after write
  • exists(path) - or get(path) != null whether document exists before write
  • existsAfter(path) - or getAfter(path) != null whether document exists after write
	allow write: if exists(/databases/${database)/documents/collectionName/documentID)

Pricing#

  • You are only charged one read for multiple requests to the same document.
  • You are only charged one read for the same document in a transaction or batch.
  • Some document access calls may be cached and don’t count against you

Clean Code and Limitations#

You need to model your data correctly to avoid the least amount of reads, keep your data consistent, and not have too much hassle. This requires you to know Firestore well, when to use Functions or the Server Environment, and how to write clean code.

Reusable Functions#

I personally avoid using variables in the match path. You can get the data you need from the request and resource variables anywhere in your code; there is no reason to create extra function parameters for global variables.

Notable Function Limitations#

  • Maximum of 7 function arguments
  • Maximum of 10 let variables per function
  • Maximum of 20 function call depth
  • Maximum of 1000 expressions evaluated per request

No Loops#

You cannot do loops or recursion in Firestore. This means you must set verifiable limits to verify a data set like tags. Your function would need to run a string test 1-5 times, for example. Otherwise, you would use Firebase Functions to prevent problematic documents (after the write).

Match#

Put all your match statements in one place, usually at the top or bottom of the document. The top will allow you less scrolling to see what you need. I like to use one function for every match call to keep in line with the Single Responsibilty Principle and to have cleaner code. However, you must keep track of the function dependencies to not reach the Firestore Rules Limitations.

	
  match /databases/{database}/documents {
  
    // MATCHES

    match /users/{document} {
    
            allow read, write: if allowUser();
            
    }
    
    // FUNCTIONS
    
    function allowUser() {
    
      return isLoggedIn() && isOwner()...
      
    }
    
    ...
    
  }

Writing Firebase Security Rules is an art.

J


Related Posts

© 2024 Code.Build