Adding an X-Auth-Key to a Firebase Function stored in Google Secret Manager

Firebase cloud functions can be secured through various mechanisms supplied by Firebase with out-of-the-box features:

  1. Functions can easily determine whether the user is being called by a user who has been authenticated by Firebase Auth
  2. We can use Firebase App Check to ensure Functions can only be called from specific apps (e.g. iOS, Android, Web). In this case we could allow anonymous use of a function--but only if a user is using our own apps to make the remote function call.
  3. We could open a function up to anonymous calls -- for example a public function that should be available to external application we can't include in App Check.

These are all common scenarios.  But what if we had a function that:

1.  Needs to be called by an external caller
1.  The caller is not an app we created or can include in App Check
1.  Is not used by a caller who signs in with Firebase Auth

A common scenario where this can occur is when we may expose some functionality in our Firebase backend to a backend process hosted elsewhere, i.e. a server-to-server integration. In this post I'm going to describe how to implement server-to-server security using a static X-Auth-Token header.

What is an X-Auth-Token

An X-Auth-Token is an established technique to allow external clients to a server API call to provide custom authentication via a static key included in the HTTP header. Static keys in headers is a secure method of transmission, as an HTTPS request header travels inside the encrypted payload. However it isn't perfect, since the token is static and not easily rotated over time or changed in the event of a compromised token.

Many modern web services have implemented more modern approaches, such as server-to-server OAuth, as such tooling can implement features such as automatic key rotation and renewal. However, OAuth is more complex and not always available, so a well managed token approach can still be a valid.  As with all security designs, do gauge the relative strength and risks of any design you choose.

This post will deal specifically deal with locking a Firebase Cloud Function down with a single X-Auth-Token.  Because the token is a piece of secret information, we'll store the token in the Google Secret Manager so that it won't exist in source code nor in a server environment.

Creating a Function

We'll start with a simple "Hello, World" function. The complexity of the function isn't important, since the focus of this post is how to secure the function so only callers that provide the correct token in the header will cause the function to complete successfully.

Initial function

const functions = require('firebase-functions')

exports.helloWorld =
  functions.https.onCall(async (data, context) => {
    return {
      message: "Hello, World!"
    }
  })

This is a valid Firebase cloud function that only returns a simple message in a JSON payload.

To call this function is also simple:

curl -X "POST" "https://{project}.cloudfunctions.net/helloWorld" \
     -H 'Content-Type: application/json' \
     -d $'{
				  "data": {}
				}'

Since I haven't added any rules to the GCP API gateway or implemented App Check to secure the function, this function is available to unauthenticated callers anywhere on the Internet.  This may be what we want--but often it isn't, so let's lock it down with an auth token.

Adding the X-Auth-Token header

Let's extend the function by checking whether an X-Auth-Token header has been sent with the API call--and if so compare it to a static string we store in the function source code.

const functions = require('firebase-functions')

exports.helloWorld =
  functions.https.onCall(async (data, context) => {
  	if (context.rawRequest.header('X-Auth-Token') != 	// 1
        'ECFCE030-E22A-4D53-9784-C74D9674A10E') {			// 2
        throw new functions.https.HttpsError(
        'permission-denied', 'Missing X-Auth-Token.') // 3
 		}
      
    return {
      success: true,
      message: "Hello, World!"
    }
  )

Here's what we've changed

  1. First, we pulled in the X-Auth-Header string value the caller sent.
  2. Second, we compare it to a static UUID we created to use as the auth token.
  3. If the comparison fails, we return a permission denied (403) error.

Passing the X-Auth-Token

Now that the function requires an auth token, we need to add it to the code that calls the function:

curl -X "POST" "https://{project}.cloudfunctions.net/helloWorld" \
     -H 'Content-Type: application/json' \
     -H 'X-Auth-Token: ECFCE030-E22A-4D53-9784-C74D9674A10E' \
     -d $'{
				  "data": {}
				}'

Securing the Token

So far we've accomplished the objective of requiring the API caller to give us the pre-shared token, but we've created a new security problem by placing a static secret into the app's source code.  This is a bad practice, since the key will be committed to our source repository, and can be compromised if the source code ever is available to unauthorized users either by process or security breach.

We could make this a little better by placing the key in the server process environment--but that's only slightly better since we can't be sure the server environment could never be compromised or copied to some other persistent storage location.

Google Secret Manager

To close the new security hole, we'll add Google Secret Manager to the Firebase project and store the security token in a vault where nobody can access the key without appropriate privileges.

What is Secret Manager?

Secret Manager is basically a vault where we can store a secret that can be accessed as the cloud function runs, but isn't stored either in source code or in the server's environment.  In the event that a hacker downloaded either the code or the server configuration where the code runs, they still wouldn't have the secret, since it's injected into the function's RAM as the function runs, but is never persisted within the runtime environment.

Secret manager is a Google Cloud Platform (GCP) feature, and like many GCP features is available to Firebase projects. We just need to enable it for the Firebase project, which we'll do next.

Enable Secret Manager

In a web browser authenticated to Firebase with admin privileges, browse to the Secret Manager landing page:

https://console.developers.google.com/apis/api/secretmanager.googleapis.com/overview?project={projectId}

where {projectId} is the Firebase project's unique ID

Tap the "Enable" button to enable the API

Secret Manager Screen on GCP

Store the token in Secret Manager

Now that Secret Manger is enabled, we can store the authorization token using a text identifier.  At the command line within your project folder, use the Firebase CLI to store the secret with a name:

% firebase functions:secrets:set X_AUTH_TOKEN
? Enter a value for X_AUTH_TOKEN [hidden]
✔  Created a new secret version projects/9999999999/secrets/X_AUTH_TOKEN/versions/1

Now we've stored the actual secret in a vault, and can use the secret name X_AUTH_TOKEN to access it from within the function.

Access the Auth Token from the Function

Now let's add code to the function so that Firebase Cloud Functions will use the Google Secret Manger to inject the auth token into the function at execution time.

const functions = require('firebase-functions')

// 1
const runtimeConfig = {
  secrets: ['X_AUTH_TOKEN'],
}

exports.helloWorld =
  functions.runWith(runtimeConfig).https.onCall(async (data, context) => { // 2
  	if (context.rawRequest.header('X-Auth-Token') != 	
        process.env.X_AUTH_TOKEN) {			// 3
        throw new functions.https.HttpsError(
        'permission-denied', 'Missing X-Auth-Token.') 
 		}
      
    return {
      success: true,
      message: "Hello, World!"
    }
  )
  1. At the top of the file, we create an array of secrets that we'd like to inject into the function.  It's possible we have many secrets in the vault, but the runtimeConfig helps ensure only as much work as needed is done to inject secrets. Secret Manager as a generous free tier--but asking for unnecessary secret values will use up the free tier faster.
  2. The runWith(runtimeConfig) inline call injects the secrets listed in runtimeConfig into the function as it's invoked.
  3. The X_AUTH_TOKEN value is accessed as an environment variable.  Notably, even thought he Node.js function thinks it's accessing the secret from the process environment, the value is actually not stored there. The injection at //2 makes this work.

Conclusion

This post covered a few different techniques:

  1. Creating Firebase cloud functions
  2. Out of the box ways to secure function execution (Firebase Auth, App Check)
  3. Adding a pre-shared X-Auth-Token to a function to support server-to-server integrations
  4. Storing secret values in Google Secret Manager and accessing those values from a Firebase cloud function