Are your HTTP Cloud Functions secure? Do you know if anyone with the URL can access them? Do you know which users are calling your functions?

These were all questions I once made after encountering several HTTP functions in a project that were using the admin SDK to write data to Firestore, but were open, which means that, basically, anyone with the URL had access to write stuff to Firestore.

NOTE: Remember that the admin SDK doesn’t care about security rules 😝.

As a best practice (more like as a mandatory practice), we need to make sure we’re locking our api endpoints so that only the people we want can access them.

Today you’ll learn how to lock your HTTP functions and how to send valid authentication credentials to them using the firebase sdk or angularfire.

The first thing we’ll do is create our function, let’s say for example that we want to create a POST endpoint, where whenever we send some information it will add something to the database.

Let’s say I have a functionality in the platform so that registered users can save their progress in the courses, and I have an HTTPS function called resetUserProfile(), and when users call the function it removes all their progress from Firestore to give them a “Start from scratch” kind of feel.

The first thing we need to do, is to create the function:

import * as admin from 'firebase-admin';
import * as functions from 'firebase-functions';
const db = admin.firestore();
const auth = admin.auth();

exports.resetUserProfile = functions.https.onRequest(
  async (req: functions.https.Request, res: functions.Response<any>) => {
    if (req.method !== 'POST') {
      return res.status(400).send('Bad request, this endpoint only accepts POST requests');
    }
  }
);

I always like to add a bit of extra validation for the request type, to make sure that only POST requests are accepted (Or GET, or PUT, depends entirely on what you’re building).

After we have the function and ensure that only POST requests are valid, we’re going to get the user’s authorization token, for that we’re going to look inside the headers, get the authorization header, and then extract the token from there:

exports.resetUserProfile = functions.https.onRequest(
  async (req: functions.https.Request, res: functions.Response<any>) => {
    if (req.method !== 'POST') {
      return res.status(400).send('Bad request, this endpoint only accepts POST requests');
    }

    const idToken: string = req.headers.authorization?.split('Bearer ')[1];
    if (!idToken) {
      return res.status(401).send('You are not authorized to perform this action');
    }
  }
);

Here’s what’s going on:

  • We’re accessing the headers from our request object.
  • Inside the headers we’re looking for the authorization header: req.headers.authorization?.
  • The authorization header looks something like this 'Bearer superLongStringTokenHere', so we’re using .split('Bearer ')[1] to break that into an array where 'Bearer ' is the item at index 0, and the token is the item at index 1.
  • And lastly we’re taking the item at index 1, the token, and assigning it to our idToken variable.

Then we perform another sanity check, if the token is not there, we return an authorization error and terminate the function.

So far we’ve validated 2 things, 1) that the requests comes from a POST call, and 2) that the request has the authorization token in the headers.

Now we need to actually make sure that the token is valid, and that it belongs to one of our users.

For that, we’ll use the admin SDK, inside the admin SDK’s auth module, we’ll find a handy function that will do all the heavy listing for us:

exports.resetUserProfile = functions.https.onRequest(
  async (req: functions.https.Request, res: functions.Response<any>) => {
    if (req.method !== 'POST') {
      return res.status(400).send('Bad request, this endpoint only accepts POST requests');
    }

    const idToken: string = req.headers.authorization?.split('Bearer ')[1];
    if (!idToken) {
      return res.status(401).send('You are not authorized to perform this action');
    }
    try {
      const decodedIdToken: admin.auth.DecodedIdToken = await auth.verifyIdToken(idToken);
    } catch (error) {}
  }
);

The verifyIdToken() function takes the authorization token and verifies it, returning either an error or the DecodedIdToken.

DecodedIdToken is an interface that has a lot of information, we mostly care about our user’s information:

interface DecodedIdToken {
  email?: string; // Our user's email
  email_verified?: boolean; // If the email is verified
  phone_number?: string; // User's phone number if available in Firebase Auth
  picture?: string; // User's picture if available in Firebase Auth
  uid: string; // The user's UID.
}

The interface has more information, but we mostly care about the uid if we want to have attach what happens in the function to the user who performed it.

After that all we’d need to check is, did the token decode successfully? Or were there any issues.

If there were any issues, terminate the function with an error, if the token is there, then we can use the admin SDK again, to check that UI and make sure the user is in our project. (In case someone is generating the token from a different project or app)

exports.resetUserProfile = functions.https.onRequest(
  async (req: functions.https.Request, res: functions.Response<any>) => {
    if (req.method !== 'POST') {
      return res.status(400).send('Bad request, this endpoint only accepts POST requests');
    }

    const idToken: string = req.headers.authorization?.split('Bearer ')[1];
    if (!idToken) {
      return res.status(401).send('You are not authorized to perform this action');
    }

    try {
      const decodedIdToken: admin.auth.DecodedIdToken = await auth.verifyIdToken(idToken);
      if (decodedIdToken && decodedIdToken.uid) {
        const user: firebase.User = admin.auth().getUser(decodedIdToken.uid);
        if (user) {
          // Here we can safely allow our users to perform whatever
          // logic the function had.
        } else {
          return res.status(401).send('You are not authorized to perform this action');
        }
      }
    } catch (error) {
      return res.status(401).send('You are not authorized to perform this action');
    }
  }
);

Once our function is secure, we need to understand how to call it, for that, we can create a master POST caller in our app’s service, that takes whatever POST call you want to make, gets the token, adds it to the headers, and makes the request.

We’ll do this using AngularFire, assuming we’re in an Ionic/Angular project, if you’re on a different project, check the JavaScript SDK for Firebase, it’s almost identical, since AngularFire is mostly a wrapper on that SDK.

Let’s say we have a data service in angular called data.service.ts, and it looks like this:

import { HttpClient } from '@angular/common/http';
import { Injectable } from '@angular/core';

@Injectable({ providedIn: 'root' })
export class DataService {
  constructor() {}
}

The first thing we need to do, is to import and inject everything we need:

import { HttpClient } from '@angular/common/http';
import { Injectable } from '@angular/core';
import { first } from 'rxjs/operators';
import { Observable } from 'rxjs';
import { AngularFireAuth } from '@angular/fire/auth';

@Injectable({ providedIn: 'root' })
export class DataService {
  constructor(private http: HttpClient, private afAuth: AngularFireAuth) {}
}

We’re going to be using AngularFireAuth to get the user’s token, and the HttpClient to make the actual request.

Then, we’ll add a function to generate the auth heathers, this function will get the current user, their token, and return the headers with the proper token.

import { HttpClient } from '@angular/common/http';
import { Injectable } from '@angular/core';
import { first } from 'rxjs/operators';
import { Observable } from 'rxjs';
import { AngularFireAuth } from '@angular/fire/auth';

@Injectable({ providedIn: 'root' })
export class DataService {
  constructor(private http: HttpClient, private afAuth: AngularFireAuth) {}

  async getAuthHeaders() {
    const user: firebase.User = await this.afAuth.user.pipe(first()).toPromise();
    const token: string = await user.getIdToken();
    return {
      headers: {
        'Content-Type': 'application/json',
        Authorization: `Bearer ${token}`
      }
    }
  }
}

And lastly, we’ll create a function that makes our HTTP call, it should get the headers ready, and send them to the request.

import { HttpClient } from '@angular/common/http';
import { Injectable } from '@angular/core';
import { first } from 'rxjs/operators';
import { Observable } from 'rxjs';
import { AngularFireAuth } from '@angular/fire/auth';

@Injectable({ providedIn: 'root' })
export class DataService {
  constructor(private http: HttpClient, private afAuth: AngularFireAuth) {}

  async getAuthHeaders() {
    const user: firebase.User = await this.afAuth.user.pipe(first()).toPromise();
    const token: string = await user.getIdToken();
    return {
      headers: {
        'Content-Type': 'application/json',
        Authorization: `Bearer ${token}`
      }
    }
  }

  async createPostRequest(url: string, body: any): Promise<Observable<any>> {
    const headers = await this.getAuthHeaders();

    return this.http.post(url, body, { headers });
  }
}

And that’s it, your function should be secure now, and you have a proper way to call it.

Also, if you’re not going to call the function for multiple places, and only from your app, Firebase also has Callable Cloud Functions which you can call from the SDK and remove A LOT of that code 😃

Do let me know if that’s something that interests you and I’ll write more about it 🔥

This article is extracted from The Firebase Security Guide, which is a complete course on Firebase Security covering authentication, Firestore, Cloud Storage, and Cloud Functions.

You can enroll here.