Patterns for security with Firebase: offload client work to Cloud Functions

Doug Stevenson
Sep 18 · 7 min read

One of the great advantages to working with a product suite like Firebase is its ability to provide robust backend services to your app, while minimizing the hassle of running your own backend servers. In fact, many types of apps can avoid the need to write any sort of backend code. This is because Firebase provides security rules for Cloud Firestore, Cloud Storage, and Realtime Database that work in tandem with Firebase Authentication, to help you enforce which authenticated users can read and write what data in your app.

However, for apps that place a value on security, sometimes security rules aren’t enough to provide maximal protection. One of the tenets of highly secure systems is called the principle of least privilege, which says that a user’s access to a system should include only those privileges which are essential to the tasks they’ve been given. What does that mean for an app that uses Firebase? Well, that depends on 1) what operations the storage system allows you to control, and 2) what your app intends for the user to do with those operations.

Let’s take Cloud Firestore as our storage system. Firebase gives direct access to Firestore from an app using the Firebase SDK, and that access is protected by security rules that you deploy to your project. As I mentioned before, you can control read and write access, but it gets more involved than that. Write access breaks down in to create, update, and delete operations. And read access breaks down into get and list. These are called granular operations (video tutorial), and you’ll want to make use of them as much as possible in order to give the user “least privilege” to the data they work with.

A practical example

Here are a couple common requirements for an app:

  • When a user creates an account through Firebase Authentication, also create a document in Cloud Firestore that tracks some data about the user.
  • When a user deletes their account, also delete the document.

I’ll use web client code samples here to illustrate the first task (other mobile client platforms will be similar):

async function createAccount() {
// create the user account, get credentials back
const userCredential = await firebase.auth()
.createUserWithEmailAndPassword(email, password)
// pull the user’s unique ID out of the result
const uid = userCredential.user.uid
// Build a reference to their per-user document in the
// users collection
const userDocRef = firebase.firestore()
.collection('users').doc(uid)
// Add some initial data to it
await userDocRef.set({
createdAt: firestore.firestore.FieldValue.serverTimestamp(),
credits: 5
})
}

Notice that the initial contents of the document include a timestamp field with the server timestamp for the moment the document was created, and a number of initial credits (if, say, this were a game with some form of currency).

Before I go any further, I should point out that no client code is safe from tampering. App developers should make the assumption that their code could be changed in some way, or maybe not even run at all.

Without any protection, the code above could be modified to lie about the date of addition, or worse, lie about the number of initial credits. I don’t know about you, but I’d really like the data in that document to be accurate, because I might want to query for users accounts who were created around a certain date range, and I definitely don’t want to give away free credits in the game! But I do want the user to be able to read this document so they can see how many credits remain.

Lock it down with security rules

With Firebase security rules, it’s possible to check both of those values before they actually get added:

rules_version = '2';
service cloud.firestore {
match /databases/{database}/documents {
match /users/{uid} {
allow create: if
request.auth.uid == uid &&
request.resource.data.createdAt == request.time &&
request.resource.data.credits == 5;
allow get: if request.auth.uid == uid;
}
}
}

These rules are saying that:

  1. Only the current user can create their own document
  2. The new document’s createdAt field must be set by the server timestamp
  3. The new document’s credits field must be 5
  4. The user can get their own document and no others in this collection

This is great! The granular permissions for create and get really help out here, because they enforce that the user has to create the document with specific fields, and they can’t be updated or deleted later.

But there are a couple problems. If I ever want to change the number of initial credits, I have to do it in two places simultaneously: both the code and the rule. This isn’t really possible to do smoothly, since not all clients can possibly load and use the code update at the exact same time that the new rules take effect. Also, if I want to change the initial credits based on some condition (e.g. day of signup, use of a promo code), that might be be difficult or impossible to express in security rules.

So, what’s a better way to do this?

Push secure logic into Cloud Functions

Sometimes code is best deployed to a backend you control, so you can update it at will, and it can’t be tampered with by malicious clients. For the above case, Cloud Functions is a great fit, because you can take advantage of an authentication trigger that automatically runs when a user account is created in Firebase Authentication.

Here’s the code to create that document in an auth trigger (I use TypeScript, but if you prefer JavaScript, it should be easy to adapt):

import * as functions from 'firebase-functions'
import * as admin from 'firebase-admin'
admin.initializeApp()
const firestore = admin.firestore()
export const authOnCreate =
functions.auth.user().onCreate(async user => {
console.log(`Creating document for user ${user.uid}`)
await firestore.collection('users').doc(user.uid).set({
createdAt: admin.firestore.FieldValue.serverTimestamp(),
credits: 5
})
})

With this function deployed to my project using the Firebase CLI, whenever a user account is created, the new document will be created with exactly the properties I choose. And, if I want, I could do extra work to determine how many initial credits the user should start with, and deploy that logic immediately, whenever I want.

This is even better now, but there’s a new kind of a problem lurking here. Since the client isn’t creating the document, how does it know when it’s been created, so it can start using it? This function fires asynchronously, some time after the account is created, and the client code that creates the account doesn’t wait for it to execute. The call to createUserWithEmailAndPassword will finish immediately when the account is created and not wait for the document.

The client can still build a reference to the document, since it’s based on the known account UID. But it would have to poll that document periodically until it finally exists. Polling is generally an undesirable option if it can be avoided. Fortunately we have a much better option.

Instantly notify a client when the Cloud Function completes

The best solution here is to have the client listen for realtime updates to that document. Now you might be concerned if it’s an error to attempt to listen to a document that doesn’t exist. After all, the client will almost certainly start listening to the document before the Cloud Function creates it. Fortunately, this isn’t a problem at all. A Cloud Firestore client can listen to documents and queries that yield no data initially, but the listener will be triggered as soon as a matching document is available. This can be used on the client to know when all the work of the function is complete, so it can advance its UI and start using that new data.

Here’s the same client code from before, but updated to use a listener to know immediately when the document is created:

async function createAccount() {
// create the user account, get credentials back
const userCredential = await firebase.auth()
.createUserWithEmailAndPassword(email, password)
// pull the user’s unique ID out of the result
const uid = userCredential.user.uid
// Build a reference to their per-user document in the
// users collection
const userDocRef = firebase.firestore()
.collection('users').doc(uid)
// Return a promise that resolves when the document
// becomes available
return new Promise((resolve, reject) => {
const unsubscribe = userDocRef.onSnapshot({
next: snapshot => {
unsubscribe()
resolve(snapshot.data())
},
error: error => {
unsubscribe()
reject(error)
}
})
}
}

Note here that a new promise is created to track the document. userDocRef has a listener attached, and as soon as there’s an document available, the promise becomes resolved with the contents of the document. It’s important to remember that onSnapshot returns an unsubscribe function that should be called when the listener is no longer needed.

The security rules for this now reduce down to simply:

rules_version = '2';
service cloud.firestore {
match /databases/{database}/documents {
match /users/{uid} {
allow get: if request.auth.uid == uid;
}
}
}

This ensures that a per-user document can only be read by that same user when signed into the app. There are no rules for document creation required, because backend SDKs used with Cloud Functions always bypass security rules completely. Rules only apply to access from mobile and web clients, and use of the Firestore REST API when provided a Firebase Authentication token.

The system diagram for this process looks like this:

And when the user account is deleted?

If you don’t want that document hanging around after the user account is deleted, you can use an onDelete auth trigger as well:

export const authOnDelete =
functions.auth.user().onDelete(async user => {
console.log(`Deleting document for user ${user.uid}`)
await firestore.collection('users').doc(user.uid).delete()
})

This is especially important if your app needs to be GDPR compliant.

I want to see it in action!

For a complete, runnable web page and source code that illustrates this in action, visit this gist to see the HTML, JavaScript, Cloud Functions, and security rules code.

Takeaway

Be mindful of application security! Always use Firebase security rules to minimize what a user can do in your app. For application logic that absolutely must not be tampered with, push that to Cloud Functions or some other backend you control.

More posts about Firebase security rules

Firebase Developers

Tutorials, deep-dives, and random musings from Firebase developers all around the world. Views expressed are those of the authors and don’t necessarily reflect those of Firebase or its parent companies.

Doug Stevenson

Written by

Google Developer Advocate with the Firebase team

Firebase Developers

Tutorials, deep-dives, and random musings from Firebase developers all around the world. Views expressed are those of the authors and don’t necessarily reflect those of Firebase or its parent companies.

Welcome to a place where words matter. On Medium, smart voices and original ideas take center stage - with no ads in sight. Watch
Follow all the topics you care about, and we’ll deliver the best stories for you to your homepage and inbox. Explore
Get unlimited access to the best stories on Medium — and support writers while you’re at it. Just $5/month. Upgrade