Firebase security rules for your Flamelink project

So you are finally ready to take the training wheels off of your Flamelink project and take it live. Before you do that, have you set proper rules on your database? No really, you should!

If this is the first time you are hearing about Flamelink, a CMS for Firebase, check out our website to get started. After linking up your Firebase project to Flamelink head back here to read about securing your content.

Not too long ago, a new Firebase project shipped in test mode, ie. reads and writes were open to anyone on the real-time database. Since then the good folks at Firebase decided to change that and default to no read or write access in locked mode. This was done because many developers never bothered to tighten security rules for their projects running in production, leaving their databases open to anyone.

Now, Flamelink cannot work when your DB is in locked mode, because we would not be able to read/write to the DB from your browser. The only way to access your database in locked mode is from a server environment, which will require access via a service account. At Flamelink, we've decided against going that route and leave you, the end user, in full control of your project and the level of access you are comfortable in giving us while still sleeping at night. This comes at a cost in terms of the seamless user experience we can offer, and we might provide both options in the future, but I digress.

To quickly get started with Flamelink we suggest you set the following database rules for the RTDB (real-time database):

{
  "rules": {
    "flamelink": {
      ".read": "auth != null",
      ".write": "auth != null",
      "users": {
        ".indexOn": ["email", "id"]
      }
    }
  }
}

In plain English, this reads:

No access outside the "flamelink" namespace BUT read and write access to authenticated users inside the "flamelink" namespace.

The user's index on the “email” and “id” fields are simply for better query performance and not important for this article on access control.

This is fine to quickly get started, but you can imagine it is not production-ready security to allow any authenticated user to write to your database. On the flip side, you might want some of the content to be readable by anyone regardless of whether they are logged in or not — think blog posts on your website, etc. So how can this be improved? Let's look at a few options.

Things to know

There are a few things to know about setting security rules for the RTDB:

  1. Security rules are completely ignored when accessed from a server, they are only applied when accessed by a client — the browser
  2. If a rule gives read/write access to a parent node any other child nodes further nested in the DB structure will also have access. In other words, you can't set a rule to false if it is already true from a rule higher in the DB structure.

Watch this video for a really good introduction to the RTDB security rules if you are not already familiar:

Read access for your app or website

The easiest one is to give read access to anyone for non-sensitive content, so we'll tackle that first.

{
  "rules": {
    "flamelink": {
      ".read": "auth != null",
      ".write": "auth != null",
      "users": {
        ".indexOn": ["email"]
      },
      "environments": {
        "$environment": {
          "content": {
            "nonSensitiveContentType": {
              ".read": true
            }
          }
          "schemas": {
            ".read": true
          }
        }
      }
    }
  }
}

What you need to notice is the “nonSensitiveContentType” property, which you can replace with your specific content type's key. This is specific to your data, so take a look in your database. You can do this for as many of the content types as you like. If you want you can make all content readable as well by just setting:

"content": {
  ".read": true
}

This is exactly what we've done for “schemas” in our example. If you use the official Flamelink JavaScript SDK, you will have to give read access to “schemas”, since this is used to determine if fields are valid, relational and some other goodies like caching.

Another option for read-access for your app users is to still require users to be authenticated but then use Firebase's anonymous sign-in. The benefit this would give you is that your DB will only be readable from within your app (or whether you allow authentication for your project) and not via the REST endpoints for instance.

Write access for specific users

To restrict write access to your DB to only your Flamelink CMS users, you can specify the unique IDs (UID's) in your rules like this:

{
  "rules": {
    "flamelink": {
      ".read": "auth != null",
      ".write": "auth.uid === '2TnyIXYi3FPeizykrJiLT972Oy53'",
      "users": {
        ".indexOn": ["email"]
      }
    }
  }
}

You can find the UID for your users under the “Authentication” section in your Firebase console. You can very easily specify multiple UIDs as well:

".write": "
    auth.uid === '2TnyIXYi3FPeizykrJiLT972Oy53' ||
    auth.uid === 'LOkg1qVvLgTHWPyOkeBgrGaNuHy3'
  "

If you decided to anonymously log in all your app users, you can further restrict writes by checking for the “anonymous” provider:

".write": "auth.provider !== 'anonymous'"

Very dynamic rules

I want to start off, by saying that we do not suggest that you have to do this, but that it is possible. Continue…

In Flamelink, users are assigned to Permission Groups, each with a unique ID. These permission groups map to certain permissions in the app. A permission group could, for instance, be configured to allow only "view" access for schemas, but full CRUD access for content. We can make use of these permission groups to dynamically restrict access on the database level.

Bare with me, this might get nasty. We'll first look at how we can enforce "view" permissions on your content types, but the same technique can be used for any of the other CRUD actions.

{
  "rules": {
    "flamelink": {
      ".read": "auth != null",
      ".write": "auth != null",
      "environments": {
        "$environment": {
          "content": {
            "$contentType": {
              "$locale": {
                ".read": "auth != null
                  && root
                      .child('flamelink')
                      .child('permissions')
                      .child(
                        root
                          .child('flamelink')
                          .child('users')
                          .child(auth.uid)
                          .child('permissions').val() + ''
                      )
                      .child('content')
                      .child($environment)
                      .child($contentType)
                      .child('view').val() === true"
              }
            }
          }
        }
      }
    }
  }
}

Wow! What the heck?! Okay, let's break that down because the idea is simple, the syntax not so much. I promise it will make sense.

The idea: Get the user's permission group and check if that permission group is set up to allow “view” permissions for the particular content.

The syntax: The rule is made up of two parts: getting the permission group ID and then checking the permission configuration for that group.

root
  .child('flamelink')
  .child('users')
  .child(auth.uid)
  .child('permissions')
  .val() + ''

This code starts at the root of your database and drills down to flamelink.users.<uid>.permissions, where <uid> is the user ID of the user trying to access the DB. The value of this database field is an integer, so we cast it to a string with + '' so that we can use it in the next part of our rule.

root
  .child('flamelink')
  .child('permissions')
  .child(<our-previous-query>)
  .child('content')
  .child($environment)
  .child($contentType)
  .child('view')
  .val() === true

Again, we start at the root of the DB and drill down until we get to the actual permission group's configuration: flamelink.permissions.<user-permission-group>.content.<environment>.<content-type>.view.

Each permission group configuration consists of the following 4 boolean properties that map to a standard CRUD config:

{
  "create": true,
  "delete": false,
  "update": true,
  "view": true
}

To check for any of the other permissions, simply replace "view" with "update", "delete" or "create".

You might have also noticed the auth != null part at the beginning of the rule. That is to ensure we're still checking that the user is logged in, otherwise, all our hard work would be undone by someone simply not logged in.

That is it for the ".read" rule. The ".write" rule is similar to our reads, but more complex because we need to also take into account what the user is trying to do to the data to determine whether we should check the create, update or delete config.

We're brave developers, so let's continue.

{
    ".write": "auth !== null &&
    ((!data.exists() &&
      root
        .child('flamelink')
        .child('permissions')
        .child(
          root
            .child('flamelink')
            .child('users')
            .child(auth.uid)
            .child('permissions')
            .val() + ''
        )
        .child('content')
        .child($environment)
        .child($contentType)
        .child('create')
        .val() === true) ||
      (!newData.exists() &&
        root
          .child('flamelink')
          .child('permissions')
          .child(
            root
              .child('flamelink')
              .child('users')
              .child(auth.uid)
              .child('permissions')
              .val() + ''
          )
          .child('content')
          .child($environment)
          .child($contentType)
          .child('delete')
          .val() === true) ||
      (data.exists() && newData.exists() &&
        root
          .child('flamelink')
          .child('permissions')
          .child(
            root
              .child('flamelink')
              .child('users')
              .child(auth.uid)
              .child('permissions')
              .val()
          )
          .child('content')
          .child($environment)
          .child($contentType)
          .child('update')
          .val() === true))"
  }

Now that we've ripped off the bandage, what is happening here?

Apart from the auth != null check for logged in users, there are 3 distinct parts to our rule, each dealing with a different action (create, delete and update).

For our create action we make use of Firebase's data.exist() method to check if no data currently exist for the particular content. That is how we know someone is trying to add new data.

For our delete action, we use the newData.exists() method to check if new data would not exist. If the user's action would result in no new data, we know they're trying to delete something.

For our last update action, we combine the data.exists() and newData.exists() methods to determine that a user is trying to change existing data to something else.

That was not so bad, was it?

For a full example of how this can be applied, see this gist.

This approach is not without its limitations. Since Flamelink is an evergreen and always-evolving product, new features are constantly added which could result in new nodes added to the database. If you tie down the database so much that we cannot make the necessary updates to your database structure, you won't have access to the shiny new features. You can get around this by combining the UID specific rule we looked at earlier with this dynamic setup and ensure that if the user currently logged in is the owner of the project any writes can be made to the database. This would ensure that when new features are rolled out and the owner logged into the project, the necessary DB structure changes are applied.

With this said, we very rarely make structural changes because of the evergreen nature of the product.

Firebase Custom Claims

We've left the best for last. The most eloquent solution is to use the lesser known feature of Firebase: Custom Claims. We would love to ship Flamelink with custom claims out of the box, but customs claims can only be set from a privileged server environment using the Firebase Admin SDK. What that means is that you, the project owner, will have to handle this yourself.

What are Custom Claims?

Simply put, custom claims are custom attributes set on user accounts. You can, for instance, set an isAdmin attribute on a user. This is very powerful because it provides the ability to implement various access control strategies, including role-based access control, in Firebase apps. The amazing thing is that these custom attributes can be used in your database's security rules.

Some ideas on how we can use them

Custom claims should only be used for access control and not to store any additional user data. It is best to store additional data in your database.

When setting your custom claims, you can keep it simple and set an attribute called flamelinkUser on all your Firebase users which should have write access to content. Alternatively, you can set as elaborate claims at you would like, but bare in mind that the custom claims payload should not exceed a limit of a 1000 bytes. It is recommended to keep it as small as possible since these claims are sent along with all network requests and a big payload can have a negative performance impact.

How to use these custom claims in our security rules?

Once set, it is extremely easy to check for custom claims in our database security rules. All custom claims are set on the authenticated user's auth token.

{
  "rules": {
    "flamelink": {
      ".read": "auth != null",
      ".write": "auth.token.flamelinkUser === true"
    }
  }
}

How to set custom claims for your users?

The only requirement for setting custom claims is that they are set from a server environment using the Firebase Admin SDK, whether that is with a stand-alone Express server you have running or using Cloud Functions for Firebase, that is up to you. The code looks something like this (example uses JavaScript, but you can use any of the supported server side languages):

// import admin SDK
const admin = require('firebase-admin')

// initialize admin app with any of the supported options
admin.initializeApp(/\* config here \*/)

// create your custom claims object (whatever you want)
const customClaims = {
  flamelinkUser: true,
}

// set the custom claims object for given UID
admin.auth().setCustomUserClaims(user.uid, customClaims)

The admin.auth().setCustomUserClaims() method returns a Promise. It is important to note that setting new custom claims overwrite any existing custom claims, so you might want to first retrieve the existing claims and update it before setting it again.

Conclusion

Hopefully, this gave you an idea of how powerful and flexible Firebase security rules are. I encourage you to read more about these rules in Firebase's documentation.

If you have any other ideas on how we can improve these security rules, please let us know in the comments below or join our Slack community, we'd love to have you.

Discuss on Twitter