How to secure your HTTP Cloud Functions
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 { Auth } from '@angular/fire/auth';
@Injectable({ providedIn: 'root' })
export class DataService {
constructor(private http: HttpClient, private auth: Auth) {}
}
We’re going to be using Auth
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 { Auth, getIdToken, authState, User } from '@angular/fire/auth';
@Injectable({ providedIn: 'root' })
export class DataService {
constructor(private http: HttpClient, private auth: Auth) {}
async getAuthHeaders() {
const user: User = await authState(this.auth).pipe(first()).toPromise();
const token: string = await getIdToken(user);
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 { Auth, getIdToken, authState, User } from '@angular/fire/auth';
@Injectable({ providedIn: 'root' })
export class DataService {
constructor(private http: HttpClient, private auth: Auth) {}
async getAuthHeaders() {
const user: User = await authState(this.auth).pipe(first()).toPromise();
const token: string = await getIdToken(user);
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 🔥