Patterns for security with Firebase: combine rules with Cloud Functions for more flexibility

Doug Stevenson
Firebase Developers
6 min readNov 8, 2019

Sometimes security rules aren’t quite flexible enough to make a decision about whether or not the user signed in with Firebase Authentication should be able to make a change to a document in Cloud Firestore. For example, you might need to consult another service or API, perform database queries, or use some complex logic to determine if the change is valid for that user. For those situations, it would be useful to write secure backend code that decides if the change is valid.

Implementing more complex logic is relatively easy using code deployed to Cloud Functions using the Firebase CLI. There are a couple options:

  1. Write an HTTP function and invoke it from the client, passing it the changes to make. It uses a backend SDK (such as the Firebase Admin SDK to deal with the database).
  2. Write a Cloud Firestore trigger to respond to a change that was just made (after the document was actually changed).

Let’s take a look at both.

Option 1: Write an HTTP function secured with Firebase Authentication

The first option is pretty straightforward: create an HTTP endpoint and pass it the data required to make the change. In order to do this securely, you’ll need to get a Firebase Auth ID token on the client, pass it to the function, and the function will need to verify it using the Firebase Admin SDK. This will make the UID available to the function, and it can decide what the user is allowed to do from there.

I won’t go into too much detail about this here, since there are a bunch of good examples already available. There are a couple official samples of this in the functions-samples repo on GitHub. Take a look at the code for an authenticated JSON API and authorized HTTPS endpoint. Also take a look at callable type functions where Firebase provides an SDK to automatically send the ID token from the client and verify it on the backend before the function is invoked.

Since Cloud Firestore backend SDKs running in Cloud Functions have privileged access to Cloud Firestore, they will bypass all security rules. So you can adjust your security rules to reject direct write access from mobile clients to the documents instead of writing rules, similar to the strategy in a prior post of mine.

The HTTP endpoint option is OK if you also require the user to be online and connected at the time of the call. Obviously, an HTTP request will fail when there’s no network connectivity. But if you want the user to be able to add and update documents using the offline persistence provided by the mobile and web client SDKs, an HTTP endpoint isn’t going to work. We can work around that with the second option.

Option 2: Write a Cloud Firestore trigger, secured with Firebase security rules

The second option allows data to be added to Firestore while the client app is offline, to be synchronized later when it comes back online. After the synchronization happens, we can arrange for a Cloud Firestore trigger to execute immediately afterward. The caveat here is that the trigger must either remove the document or undo the change if its contents violates your constraints. Let’s examine this option in more detail.

If we want to write a trigger that determines if a new document created by a particular user is valid, it naturally needs to get a hold of that user’s ID. At first glance, the EventContext object passed as the second parameter to Cloud Firestore triggers looks like it has auth data in it. Unfortunately, that data isn’t currently provided by Firestore. The good news is that we can use the per-user security rules (as described in a prior post of mine) to ensure that the UID stored in the document (either in the document ID or a document field) are correct for the user making the change.

For example, if the UID is the document ID, and validated by security rules like this, which allows write access to the matching user:

match /users/{uid} {
allow write: if request.auth.uid == uid;
}

we can write a function to trigger on that document immediately after it’s created in the users collection. The function can then make a decision about that document, then delete it if it’s invalid. In the function code below that fires in response to new document creation in the users collection, that logic is hidden behind a custom isUserDocumentValid async function:

exports.validateNewUserDocument =
functions.firestore.document('users/{uid}')
.onCreate(async (snapshot, context) => {
// Use the uid from the wildcard document ID
// The uid was previously validated by security rules
const uid = context.params.uid
// Contents of the document as a JavaScript object
const data = snapshot.data()
// Some logic that validates the contents of the document
// for that user
const isValid = await isUserDocumentValid(uid, data)
if (!isValid) {
// Delete the document if it’s invalid
console.log(`Deleting invalid document for user ${uid}`)
await snapshot.ref.delete()
}
})

If the UID is stored in a document field instead of the document ID, the security rules can validate the UID like this (assuming it’s in a field named “uid”), for all documents added to the messages collection:

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

Then the function code can safely pull it out of the field and assume it’s valid:

exports.validateNewMessageDocument =
functions.firestore.document('messages/{id}')
.onCreate(async (snapshot, context) => {
const data = snapshot.data() // UID in the document, previously validated by security rules
const uid = data.uid
const isValid = await isUserDocumentValid(uid, data)
if (!isValid) {
console.log(`Deleting invalid document for user ${uid}`)
await snapshot.ref.delete()
}
})

For handling document updates, you’d probably want to undo the change if it failed a custom validity check, like this:

exports.validateUpdatedMessage =
functions.firestore.document('messages/{id}')
.onUpdate(async (change, context) => {
const oldData = change.before.data()
const newData = change.after.data()
// UID in the document, previously validated by security rule
const uid = newData.uid
const isValid =
await isMessageDocumentChangeValid(uid, oldData, newData)
if (!isValid) {
console.log(`Undoing invalid change for user ${uid}`)
await change.before.ref.set(oldData)
}
})

You might be wondering how to let the user know if they modified a document in an invalid way. My opinion is that the client app should also be checking the validity of any changes using similar logic, and alert the user before the change is committed. In the case that the client app has been compromised and is trying to insert invalid data, there’s really no need to alert the attacker that they did something wrong.

Using security rules to force the client to write their UID correctly into the document is useful any time you’re writing a Cloud Firestore trigger and you just need to know the UID of the user that made the change. For example, you might want to log which users took what actions, for the purpose of auditing or accounting.

Security rules and Cloud Functions are both ways to protect data in Cloud Firestore. It’s usually better to lean more heavily on security rules, as much as they can do what you require. But they still combine very well with Cloud Functions to establish robust and complex security for you app.

It works for other products as well

The example I’ve given here is for Cloud Firestore, but this strategy works equally well for other products with write triggers, such as Realtime Database and Cloud Storage. The catch with Cloud Storage is that you’ll need to put the user’s UID in the metadata of the object at the time of the upload, and verify it from there using request.resource.metadata in security rules.

More posts about Firebase security rules

--

--