Back-end
13 minute read

How to Build a Role-based API with Firebase Authentication

Joaquin is a full-stack developer with over 12 years of experience working for companies like WebMD and Getty Images.

In this tutorial, we’ll build a REST API to manage users and roles using Firebase and Node.js. In addition, we’ll see how to use the API to authorize (or not) which users can access specific resources.

Introduction

Almost every app requires some level of authorization system. In some cases, validating a username/password set with our Users table is enough, but often, we need a more fine-grained permissions model to allow certain users to access certain resources and restrict them from others. Building a system to support the latter is not trivial and can be very time consuming. In this tutorial, we’ll learn how to build a role-based auth API using Firebase, which will help us get quickly up and running.

Role-based Auth

In this authorization model, access is granted to roles, instead of specific users, and a user can have one or more depending on how you design your permission model. Resources, on the other hand, require certain roles to allow a user to execute it.

Role-based Auth with illustrated

Firebase

Firebase Authentication

In a nutshell, Firebase Authentication is an extensible token-based auth system and provides out-of-the-box integrations with the most common providers such as Google, Facebook, and Twitter, among others.

It enables us to use custom claims which we’ll leverage to build a flexible role-based API.

We can set any JSON value into the claims (e.g., { role: 'admin' } or { role: 'manager' }).

Once set, custom claims will be included in the token that Firebase generates, and we can read the value to control access.

It also comes with a very generous free quota, which in most cases will be more than enough.

Firebase Functions

Functions are a fully-managed serverless platform service. We just need to write our code in Node.js and deploy it. Firebase takes care of scaling the infrastructure on demand, server configuration, and more. In our case, we’ll use it to build our API and expose it via HTTP to the web.

Firebase allows us to set express.js apps as handlers for different paths—for example, you can create an Express app and hook it to /mypath, and all requests coming to this route will be handled by the app configured.

From within the context of a function, you have access to the whole Firebase Authentication API, using the Admin SDK.

This is how we’ll create the user API.

What We’ll Build

So before we get started, let’s take a look at what we’ll build. We are going to create a REST API with the following endpoints:

Http Verb Path Description Authorization
GET /users Lists all users Only admins and managers have access
POST /users Creates new user Only admins and managers have access
GET /users/:id Gets the :id user Admins, managers, and the same user as :id have access
PATCH /users/:id Updates the :id user Admins, managers, and the same user as :id have access
DELETE /users/:id Deletes the :id user Admins, managers, and the same user as :id have access

Each of these endpoints will handle authentication, validate authorization, perform the correspondent operation, and finally return a meaningful HTTP code.

We’ll create the authentication and authorization functions required to validate the token and check if the claims contain the required role to execute the operation.

Building the API

In order to build the API, we’ll need:

  • A Firebase project
  • firebase-tools installed

First, log in to Firebase:

firebase login

Next, initialize a Functions project:

firebase init

? Which Firebase CLI features do you want to set up for this folder? ...
(O) Functions: Configure and deploy Cloud Functions

? Select a default Firebase project for this directory: {your-project}

? What language would you like to use to write Cloud Functions? TypeScript

? Do you want to use TSLint to catch probable bugs and enforce style? Yes

? Do you want to install dependencies with npm now? Yes

At this point, you will have a Functions folder, with minimum setup to create Firebase Functions.

At src/index.ts there’s a helloWorld example, which you can uncomment to validate that your Functions works. Then you can cd functions and run npm run serve. This command will transpile the code and start the local server.

You can check the results at http://localhost:5000/{your-project}/us-central1/helloWorld

A fresh Firebase app's Hello World response

Notice the function is exposed on the path defined as the name of it at 'index.ts: 'helloWorld'.

Creating a Firebase HTTP Function

Now let’s code our API. We are going to create an http Firebase function and hook it on /api path.

First, install npm install express.

On the src/index.ts we will:

  • Initialize the firebase-admin SDK module with admin.initializeApp();
  • Set an Express app as the handler of our api https endpoint
import * as functions from 'firebase-functions';
import * as admin from 'firebase-admin';
import * as express from 'express';

admin.initializeApp();

const app = express();

export const api = functions.https.onRequest(app);

Now, all requests going to /api will be handled by the app instance.

The next thing we’ll do is configure the app instance to support CORS and add JSON body parser middleware. This way we can make requests from any URL and parse JSON formatted requests.

We’ll first install required dependencies.

npm install --save cors body-parser
npm install --save-dev @types/cors

And then:

//...
import * as cors from 'cors';
import * as bodyParser from 'body-parser';

//...
const app = express();
app.use(bodyParser.json());
app.use(cors({ origin: true }));

export const api = functions.https.onRequest(app);

Finally, we will configure the routes that the app will handle.

//...
import { routesConfig } from './users/routes-config';
 
//…
app.use(cors({ origin: true }));
routesConfig(app)

export const api = functions.https.onRequest(app);

Firebase Functions allows us to set an Express app as the handler, and any path after the one you set up at functions.https.onRequest(app);—in this case, api—will also be handled by the app. This allows us to write specific endpoints such as api/users and set a handler for each HTTP verb, which we’ll do next.

Let’s create the file src/users/routes-config.ts

Here, we’ll set a create handler at POST '/users'

import { Application } from "express";
import { create} from "./controller";

export function routesConfig(app: Application) {
   app.post('/users',
       create
   );
}

Now, we’ll create the src/users/controller.ts file.

In this function, we first validate that all fields are in the body request, and next, we create the user and set the custom claims.

We are just passing { role } in the setCustomUserClaims—the other fields are already set by Firebase.

If no errors occur, we return a 201 code with the uid of the user created.

import { Request, Response } from "express";
import * as admin from 'firebase-admin'

export async function create(req: Request, res: Response) {
   try {
       const { displayName, password, email, role } = req.body

       if (!displayName || !password || !email || !role) {
           return res.status(400).send({ message: 'Missing fields' })
       }

       const { uid } = await admin.auth().createUser({
           displayName,
           password,
           email
       })
       await admin.auth().setCustomUserClaims(uid, { role })

       return res.status(201).send({ uid })
   } catch (err) {
       return handleError(res, err)
   }
}

function handleError(res: Response, err: any) {
   return res.status(500).send({ message: `${err.code} - ${err.message}` });
}

Now, let’s secure the handler by adding authorization. To do that, we’ll add a couple of handlers to our create endpoint. With express.js, you can set a chain of handlers that will be executed in order. Within a handler, you can execute code and pass it to the next() handler or return a response. What we’ll do is first authenticate the user and then validate if it is authorized to execute. If the user doesn’t have the required role, we’ll return a 403.

On file src/users/routes-config.ts:

//...
import { isAuthenticated } from "../auth/authenticated";
import { isAuthorized } from "../auth/authorized";

export function routesConfig(app: Application) {
   app.post('/users',
       isAuthenticated,
       isAuthorized({ hasRole: ['admin', 'manager'] }),
       create
   );
}

Let’s create the files src/auth/authenticated.ts.

On this function, we’ll validate the presence of the authorization bearer token in the request header. Then we’ll decode it with admin.auth().verifyidToken() and persist the user’s uid, role, and email in the res.locals variable, which we’ll later use to validate authorization.

In the case the token is invalid, we return a 401 response to the client:

import { Request, Response } from "express";
import * as admin from 'firebase-admin'

export async function isAuthenticated(req: Request, res: Response, next: Function) {
   const { authorization } = req.headers

   if (!authorization)
       return res.status(401).send({ message: 'Unauthorized' });

   if (!authorization.startsWith('Bearer'))
       return res.status(401).send({ message: 'Unauthorized' });

   const split = authorization.split('Bearer ')
   if (split.length !== 2)
       return res.status(401).send({ message: 'Unauthorized' });

   const token = split[1]

   try {
       const decodedToken: admin.auth.DecodedIdToken = await admin.auth().verifyIdToken(token);
       console.log("decodedToken", JSON.stringify(decodedToken))
       res.locals = { ...res.locals, uid: decodedToken.uid, role: decodedToken.role, email: decodedToken.email }
       return next();
   }
   catch (err) {
       console.error(`${err.code} -  ${err.message}`)
       return res.status(401).send({ message: 'Unauthorized' });
   }
}

Now, let’s create a src/auth/authorized.ts file.

In this handler, we extract the user’s info from res.locals we set previously and validate if it has the role required to execute the operation or in the case the operation allows the same user to execute, we validate that the ID on the request params is the same as the one in the auth token.

import { Request, Response } from "express";

export function isAuthorized(opts: { hasRole: Array<'admin' | 'manager' | 'user'>, allowSameUser?: boolean }) {
   return (req: Request, res: Response, next: Function) => {
       const { role, email, uid } = res.locals
       const { id } = req.params

       if (opts.allowSameUser && id && uid === id)
           return next();

       if (!role)
           return res.status(403).send();

       if (opts.hasRole.includes(role))
           return next();

       return res.status(403).send();
   }
}

With these two methods, we’ll be able to authenticate requests and authorize them given the role in the incoming token. That’s great, but since Firebase doesn’t let us set custom claims from the project console, we won’t be able to execute any of these endpoints. In order to bypass this, we can create a root user from Firebase Authentication Console

Creating a user from the Firebase Authentication Console

And set an email comparison in the code. Now, when firing requests from this user, we’ll be able to execute all operations.

//...
  const { role, email, uid } = res.locals
  const { id } = req.params

  if (email === '[email protected]')
    return next();
//...

Now, let’s add the rest of the CRUD operations to src/users/routes-config.ts.

For operations to get or update a single user where :id param is sent, we also allow the same user to execute the operation.

export function routesConfig(app: Application) {
   //..
   // lists all users
   app.get('/users', [
       isAuthenticated,
       isAuthorized({ hasRole: ['admin', 'manager'] }),
       all
   ]);
   // get :id user
   app.get('/users/:id', [
       isAuthenticated,
       isAuthorized({ hasRole: ['admin', 'manager'], allowSameUser: true }),
       get
   ]);
   // updates :id user
   app.patch('/users/:id', [
       isAuthenticated,
       isAuthorized({ hasRole: ['admin', 'manager'], allowSameUser: true }),
       patch
   ]);
   // deletes :id user
   app.delete('/users/:id', [
       isAuthenticated,
       isAuthorized({ hasRole: ['admin', 'manager'] }),
       remove
   ]);
}

And on src/users/controller.ts. In these operations, we leverage the admin SDK to interact with Firebase Authentication and perform the respective operations. As we did previously on create operation, we return a meaningful HTTP code on each operation.

For the update operation, we validate all fields present and override customClaims with those sent in the request:

//..

export async function all(req: Request, res: Response) {
   try {
       const listUsers = await admin.auth().listUsers()
       const users = listUsers.users.map(user => {
           const customClaims = (user.customClaims || { role: '' }) as { role?: string }
           const role = customClaims.role ? customClaims.role : ''
           return {
               uid: user.uid,
               email: user.email,
               displayName: user.displayName,
               role,
               lastSignInTime: user.metadata.lastSignInTime,
               creationTime: user.metadata.creationTime
           }
       })

       return res.status(200).send({ users })
   } catch (err) {
       return handleError(res, err)
   }
}

export async function get(req: Request, res: Response) {
   try {
       const { id } = req.params
       const user = await admin.auth().getUser(id)
       return res.status(200).send({ user })
   } catch (err) {
       return handleError(res, err)
   }
}

export async function patch(req: Request, res: Response) {
   try {
       const { id } = req.params
       const { displayName, password, email, role } = req.body

       if (!id || !displayName || !password || !email || !role) {
           return res.status(400).send({ message: 'Missing fields' })
       }

       const user = await admin.auth().updateUser(id, { displayName, password, email })
       await admin.auth().setCustomUserClaims(id, { role })
       return res.status(204).send({ user })
   } catch (err) {
       return handleError(res, err)
   }
}

export async function remove(req: Request, res: Response) {
   try {
       const { id } = req.params
       await admin.auth().deleteUser(id)
       return res.status(204).send({})
   } catch (err) {
       return handleError(res, err)
   }
}

//...

Now we can run the function locally. To do that, first you need to set up the account key to be able to connect with the auth API locally. Then run:

npm run serve

Deploy the API

Great! Now that we have our written the role-based API, we can deploy it to the web and start using it. Deploying with Firebase is super easy, we just need to run firebase deploy. Once the deploy is completed, we can access our API at the published URL.

Running the firebase deploy command

You can check the API URL at https://console.firebase.google.com/u/0/project/{your-project}/functions/list.

The API URL at the Firebase Console

In my case, it is https://us-central1-joaq-lab.cloudfunctions.net/api.

Consuming the API

Once our API is deployed, we have several ways to use it—in this tutorial, I’ll cover how to use it via Postman or from an Angular app.

If we enter the List All Users URL (/api/users) on any browser, we’ll get the following:

Firebase Authentication API's message: unauthorized response

The reason for this is when sending the request from a browser, we are performing a GET request without auth headers. These means our API is actually working as expected!

Our API is secured via tokens—in order to generate such a token, we need to call Firebase’s Client SDK and log in with a valid user/password credential. When successful, Firebase will send a token back in the response which we can then add to the header of any following request we want to perform.

From an Angular App

In this tutorial, I’ll just go over the important pieces to consume the API from an Angular app. The full repository can be accessed here, and if you need a step-by-step tutorial on how to create an Angular app and configure @angular/fire to use, it you can check this post.

So, back to signing in, we’ll have a SignInComponent with a <form> to let the user enter a username and password.

//...
 
<form [formGroup]="form">
   <div class="form-group">
     <label>Email address</label>
     <input type="email"
            formControlName="email"
            class="form-control"
            placeholder="Enter email">
   </div>
   <div class="form-group">
     <label>Password</label>
     <input type="password"
            formControlName="password"
            class="form-control"
            placeholder="Password">
   </div>
 </form>

//...

And on the class, we signInWithEmailAndPassword using the AngularFireAuth service.

 //... 

 form: FormGroup = new FormGroup({
   email: new FormControl(''),
   password: new FormControl('')
 })

 constructor(
   private afAuth: AngularFireAuth
 ) { }

 async signIn() {
   try {
     const { email, password } = this.form.value
     await this.afAuth.auth.signInWithEmailAndPassword(email, password)
   } catch (err) {
     console.log(err)
   }
 }

 //..

At this point, we can sign in to our Firebase project.

Signing in through the Angular app
The API response when signing in from the Angular app

And when we inspect the network requests in the DevTools, we can see that Firebase returns a token after verifying our user and password.

This token is the one we will use to send on our header’s request to the API we’ve built. One way to add the token to all requests is using an HttpInterceptor.

This file shows how to get the token from AngularFireAuth and add it to the header’s request. We then provide the interceptor file in the AppModule.

http-interceptors/auth-token.interceptor.ts

@Injectable({ providedIn: 'root' })
export class AuthTokenHttpInterceptor implements HttpInterceptor {

   constructor(
       private auth: AngularFireAuth
   ) {

   }

   intercept(req: HttpRequest&lt;any&gt;, next: HttpHandler): Observable&lt;HttpEvent&lt;any&gt;&gt; {
       return this.auth.idToken.pipe(
           take(1),
           switchMap(idToken =&gt; {
               let clone = req.clone()
               if (idToken) {
                   clone = clone.clone({ headers: req.headers.set('Authorization', 'Bearer ' + idToken) });
               }
               return next.handle(clone)
           })
       )

   }
}

export const AuthTokenHttpInterceptorProvider = {
   provide: HTTP_INTERCEPTORS,
   useClass: AuthTokenHttpInterceptor,
   multi: true
}

app.module.ts

@NgModule({
 //..
 providers: [
   AuthTokenHttpInterceptorProvider
 ]
 //...
})
export class AppModule { }

Once the interceptor is set, we can make requests to our API from httpClient. For example, here’s a UsersService where we call the list all users, get the user by its ID, and create a user.

//…

export type CreateUserRequest = { displayName: string, password: string, email: string, role: string }

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

 private baseUrl = '{your-functions-url}/api/users'

 constructor(
   private http: HttpClient
 ) { }
  get users$(): Observable&lt;User[]&gt; {
   return this.http.get&lt;{ users: User[] }&gt;(`${this.baseUrl}`).pipe(
     map(result =&gt; {
       return result.users
     })
   )
 }

 user$(id: string): Observable&lt;User&gt; {
   return this.http.get&lt;{ user: User }&gt;(`${this.baseUrl}/${id}`).pipe(
     map(result =&gt; {
       return result.user
     })
   )
 }

 create(user: CreateUserRequest) {
   return this.http.post(`${this.baseUrl}`, user)
 }
}

Now, we can call the API to get the user by its ID and list all users from a component like this:

//...
   <ul *ngIf="user$ | async; let user" 
      class="list-group">
     <li class="list-group-item d-flex justify-content-between align-items-center">
       <div>
         <h5 class="mb-1">{{user.displayName}}</h5>
         <small>{{user.email}}</small>
       </div>
       <span class="badge badge-primary badge-pill">{{user.role?.toUpperCase()}}</span>
     </li>
   </ul>

  <ul *ngIf="users$ | async; let users"
       class="list-group">
     <li *ngFor="let user of users"
         class="list-group-item d-flex justify-content-between align-items-center">
       <div>
         <h5 class="mb-1">{{user.displayName}}</h5>
         <small class="d-block">{{user.email}}</small>
         <small class="d-block">{{user.uid}}</small>
       </div>
       <span class="badge badge-primary badge-pill">{{user.role?.toUpperCase()}}</span>
     </li>
   </ul>
//...
//...

 users$: Observable&lt;User[]&gt;
 user$: Observable&lt;User&gt;

 constructor(
   private userService: UserService,
   private userForm: UserFormService,
   private modal: NgbModal,
   private afAuth: AngularFireAuth
 ) { }

 ngOnInit() {
   this.users$ = this.userService.users$

   this.user$ = this.afAuth.user.pipe(
     filter(user =&gt; !!user),
     switchMap(user =&gt; this.userService.user$(user.uid))
   )
 }

//...

And here’s the result.

All the users in our Angular app

Notice that if we sign in with a user with role=user, only the Me section will be rendered.

The view of the user resource that the user with the role user has access to

And we’ll get a 403 on the network inspector.

A 403 error in the network inspector

From Postman

Postman is a tool to build and make requests to APIs. This way, we can simulate that we are calling our API from any client app or a different service.

What we’ll demo is how to send a request to list all users.

Once we open the tool, we set the URL https://us-central1-{your-project}.cloudfunctions.net/api/users:

The API URL loaded in the Postman field ready to fire as a GET request

Next, on the tab authorization, we choose Bearer Token and we set the value we extracted from Dev Tools previously.

Setting the bearer token in Postman
The body of the response we receive

Conclusion

Congratulations! You’ve made it through the whole tutorial and now you’ve learned to create a user role-based API on Firebase.

We’ve also covered how to consume it from an Angular app and Postman.

Let’s recap the most important things:

  1. Firebase allows you to get quickly up and running with an enterprise-level auth API, which you can extend later on.
  2. Almost every project requires authorization—if you need to control access using a role-based model, Firebase Authentication lets you get started very quickly.
  3. The role-based model relies on validating resources that are requested from users with specific roles vs. specific users.
  4. Using an Express.js app on Firebase Function, we can create a REST API and set handlers to authenticate and authorize requests.
  5. Leveraging built-in custom claims, you can create a role-based auth API and secure your app.

You can read further about Firebase auth here. And if you want to leverage the roles we have defined, you can use @angular/fire helpers.

Understanding the basics

Is Firebase Auth a REST API?

Firebase Auth is a service that allows your app to sign up and authenticate a user against multiple providers such as (Google, Facebook, Twitter, GitHub and more). Firebase Auth provides SDKs with which you can easily integrate with web, Android, and iOS. Firebase Auth can also be consumed as a REST API

What is Firebase used for?

Firebase is a suite of cloud products that helps you build a serverless mobile or web app very quickly. It provides most of the common services involved on every app (database, authorization, storage, hosting).

How do I get Firebase Auth API?

You can create a project with your Google account at firebase.google.com. Once the project is created, you can turn on Firebase Auth and start using it in your app.

Which is better, Firebase or AWS?

Firebase is Google-backed product, and one of which Google is trying to grow and add more and more features. AWS Amplify is a similar product, mostly targeted to mobile apps. Both are great products, with Firebase being an older product with more features.

Is Firebase easy to use?

Firebase is a fully managed service with which you can get started very easily and not worry about infrastructure when you need to scale up. There's a lot of great documentation and blog posts with examples to quickly learn how it works.

Is Firebase good for large databases?

Firebase has two databases: Realtime Database and Firestore. Both are NoSQL databases with similar features and different pricing models. Firestore supports better querying features and both databases are designed so that querying latency is not affected by the database size.