In a prior post in this series on Firebase security rules, I discussed the use of Firebase Authentication custom claims to enforce role-based security for Firestore clients. Custom claims allows for a small amount of data to be attached to a user account, which can be used in security rules to determine if the user should be able to access documents and collections in Firestore.

There are a couple of caveats to using custom claims like this.

Custom claims can be difficult to experiment with and debug, as there is no easy way to examine or make updates to claims in the Firebase console. You have to write code for both cases, using the Firebase Admin SDK, which can be inconvenient. While custom claims can be updated at any time using the Firebase Admin SDK, the new data doesn’t appear in the client (nor in security rules) until after the client refreshes the user’s ID token. This could take up to one hour to occur automatically, or requires a full sign-out and sign-in, both of which are inconvenient.

What I want to do here is describe a way to remedy both of these issues using some fun tricks with Firestore and Cloud Functions.

Accessing custom claims programmatically

If you start a Firebase project from scratch, and you want to see the custom claims set for particular user account, the shortest path to get that done might be with a node command line script that uses the Firebase Admin SDK like this (I’m using TypeScript here, easy to convert to JavaScript):

import * as admin from 'firebase-admin' const args = process.argv.slice(2)

if (args.length !== 1) {

console.error("Give exactly one UID argument")

process.exit(1)

}

const uidArg = args[0] async function go() {

admin.initializeApp() const auth = admin.auth()

const user = await auth.getUser(uidArg)

console.log(user.customClaims) await admin.app().delete()

} go()

.catch(error => {

console.error(error)

process.exit(1)

})

This script simply gets the user account data with getUser , then prints the custom claims for that user. So, every time you want to check a user’s claims, you’d run this script on the command line, and copy/paste the UID into that command. Workable, but not terribly convenient.

If you then want to update the user’s claims, you’d need another script, which is very similar to the first, but adding a call to setCustomUserClaims . Imagine that you want to assign some sort of admin access to a user, to be checked in security rules with the name “isAdmin”. The code (without all the extra boilerplate from above) to add a claim called “isAdmin” with boolean value true looks like this:

async function go() {

admin.initializeApp() const auth = admin.auth()

const user = await auth.getUser(uidArg)

const claims = user.customClaims || {} as any

claims.isAdmin = true

await auth.setCustomUserClaims(uidArg, claims) await admin.app().delete()

}

So now, with these two scripts, you can manually check and set custom claims. But as I mentioned before, this is not terribly convenient. And also, the code to set the claims isn’t very flexible — all it knows how to do is set isAdmin to true. It gets more complicated if you want to set arbitrary keys and values.

What I would like instead is a simple UI that lets me update custom claims in a flexible way for any user. Turns out, I can reuse Firestore document editor in the Firebase console for exactly this purpose! The console can only modify Firestore documents, but I can also write a Cloud Function to mirror those changes into user account. Since custom claims are internally represented as JSON, and the document editor is capable of editing all the JSON basic data types (string, number, object, array, null), this should be sufficient for entering any valid data.

Mirror Firestore documents to user custom claims

What we want to do here is dedicate a Firestore collection called “user-claims” to contain a single document for each user, identified by their UID. Any changes to that document should mirror those changes into the user’s custom claims. This is pretty straightforward, except I’m going a bit further and also maintaining a last modification timestamp field in the document as well, which is not to be mirrored into custom claims. This complicates the code slightly, as it has to check to see if that field changed, and ignore those changes so the function doesn’t invoke itself unnecessarily. You’ll see why I’m doing this a bit later in this post. Here’s the complete function code in TypeScript:

import * as functions from 'firebase-functions'

import * as admin from 'firebase-admin'

admin.initializeApp() const auth = admin.auth() interface ClaimsDocumentData extends admin.firestore.DocumentData {

_lastCommitted?: admin.firestore.Timestamp

} export const mirrorCustomClaims =

functions.firestore.document('user-claims/{uid}')

.onWrite(async (change, context) => {

const beforeData: ClaimsDocumentData =

change.before.data() || {}

const afterData: ClaimsDocumentData =

change.after.data() || {} // Skip updates where _lastCommitted field changed,

// to avoid infinite loops

const skipUpdate =

beforeData._lastCommitted &&

afterData._lastCommitted &&

!beforeData._lastCommitted.isEqual(afterData._lastCommitted)

if (skipUpdate) {

console.log("No changes")

return

} // Create a new JSON payload and check that it's under

// the 1000 character max

const { _lastCommitted, ...newClaims } = afterData

const stringifiedClaims = JSON.stringify(newClaims)

if (stringifiedClaims.length > 1000) {

console.error("New custom claims object string > 1000 characters", stringifiedClaims)

return

} const uid = context.params.uid

console.log(`Setting custom claims for ${uid}`, newClaims)

await auth.setCustomUserClaims(uid, newClaims) console.log('Updating document timestamp')

await change.after.ref.update({

_lastCommitted: admin.firestore.FieldValue.serverTimestamp(),

...newClaims

})

})

This Firestore trigger works like this. Whenever a document in the user-claims collection is written:

Check if the _lastCommited timestamp field was updated, and if so, bail early. Create an object with the new custom claims, without the _lastCommitted field. Check if its stringified length is above the size limit, and if so, bail early. Update the claims for the UID seen in the document ID wildcard variable. Update the _lastCommitted field in the same document with the current time.

With this function deployed, now I can use the Firestore editor to effectively modify a user’s custom claims in free form. But this isn’t the only benefit of having this function in place! It also lets me write client code to automatically refresh the Firebase Authentication ID token for a signed-in user when their document is observed to change.

Immediately refreshing custom claims in the client app

With Firebase Authentication, when a user signs in, the SDK will obtain a new ID token that describes the user account, in addition to their custom claims. This ID token is what’s sent along with Firestore queries, for the purpose of checking security rules. This token lasts for an hour at most, at which point, the Auth SDK will use a long-lived refresh token to obtain a new ID token. If anything in the user’s custom claims changed, this new ID token will contain those changes.

As I mentioned earlier, sometimes you don’t really want to wait out that whole hour, or force a sign-in and sign-out on the client, just to test a change, or to grant an actual user immediate access to some resources. Fortunately, there are two facts that we can take advantage of, in order to force an immediate refresh of the ID token on the client:

Firestore clients are capable of picking up changes in documents as soon as they happen, using a snapshot listener. Firebase Auth clients can request an immediate refresh of an ID token in order to immediately pick up changes custom claims.

The first item involves setting up a listener on the document that contains the custom claims for the current user. When the client sees the _lastCommitted timestamp update, that means there are probably new custom claims to refresh.

The second one involves a straightforward API call to force refresh the current user’s ID token. I’ll use the web API as an example in this post, and make a call to getIdToken(true) to make that happen (other platforms have similar APIs for refreshing the token).

Let’s dig into the complete client code. Firstly, we need to set up an auth state listener to find out when a user signs in, so we know when to start listening to their document in the user-claims collection:

const auth = firebase.auth()

const firestore = firebase.firestore()

let currentUser auth.onAuthStateChanged(user => {

currentUser = user

if (user) {

listenToClaims()

}

else {

currentUser = undefined

}

}) function listenToClaims() {

firestore

.collection('user-claims')

.doc(currentUser.uid)

.onSnapshot(onNewClaims)

}

The listener function onNewClaims then needs to check if the _lastCommitted timestamp was updated, and if so, refresh the ID token:

let lastCommitted function onNewClaims(snapshot) {

const data = snapshot.data()

console.log('New claims doc

', data)

if (data._lastCommitted) {

if (lastCommitted &&

!data._lastCommitted.isEqual(lastCommitted)) {

// Force a refresh of the user's ID token

console.log('Refreshing token')

currentUser.getIdToken(true)

}

lastCommitted = data._lastCommitted

}

}

Also let’s not forget security rules that require each user can only get their own document:

match /user-claims/{uid} {

allow get: if request.auth.uid == uid;

}

This code is only what’s strictly required to get the client to automatically refresh. But I’ll go a bit further and add some more code that gets called when the ID token changes, and logs the isAdmin custom claim, so we can see that it updated:

auth.onIdTokenChanged(user => {

if (user) {

console.log(`new ID token for ${user.uid}`)

currentUser = user

reportAdminStatus()

}

}) async function reportAdminStatus() {

const result = await currentUser.getIdTokenResult(false)

const isAdmin = result.claims.isAdmin

if (isAdmin) {

console.log("Custom claims say I am an admin!")

}

else {

console.log("Custom claims say I am not an admin.")

}

}

Here’s a diagram of the sequential flow of data through the system.

And here’s what it looks like in action, using the Firebase console at the top to modify the isAdmin claim, and viewing the client updates in the web browser console log below it.

But that’s not all!

Use Firestore to query custom claims

One interesting side effect of mirroring custom claims from Firestore is that now those claims can be queried simply by querying the Firestore collection where they all live now. Let’s say we want people who have admin privileges via the isAdmin custom claim to be able to query everyone’s claims to see who else is an admin. This was impossible before, but super easy now.

First, a security rule update to let admins query the user-claims collection:

match /user-claims/{uid} {

allow get: if request.auth.uid == uid;

allow read: if request.auth.token.isAdmin;

}

With the new read rule, anyone with the isAdmin claim set to true can now query this entire collection. In fact, all claims are accessible under request.auth.token And now, the query itself from the web client to find all other admins:

async function doAdminStuff() {

try {

const snapshot = await firestore

.collection('user-claims')

.where('isAdmin', '==', true)

.get()

console.log(snapshot.docs.map(doc => doc.data()))

}

catch (error) {

console.error('Banned!', error.message)

}

}

Pretty slick, if I do say so myself. But before I get all self-congratulatory…

The caveats

If you want to use a system like this to mirror custom claims from Firestore into Firebase Authentication, all of the following caveats should be known:

All updates to custom claims should now go through Firestore instead of directly to Firebase Auth. Direct changes to custom claims will not mirror back to Firestore. And, unfortunately, there is currently no Cloud Function trigger that will let you know when the claims have changed directly. However, you can write some code to migrate all users’ existing custom claims into Firestore documents in order to bootstrap the system.

If the 1000 character limit for claims is exceeded in the document, the Cloud Function will catch it and prevent it, but it offers no easy way to recover from that. It might require a fair amount of extra code plumbing to propagate that error back to whomever made the change to the document.

If your security rules on the user-claims collection are not rock-solid, you could end up with a situation where a user could grant themselves admin access simply by modifying their own document. This should absolutely be prevented!

Since security rules can only take valid JSON data, writing other types of data into the user-claims document could cause problems. For example, timestamps and references don’t have a useful default format in JSON without some manual conversions. If you’re worried that someone might write invalid JSON data into a document, your function should detect this and prevent it.

More posts about Firebase security rules