close icon
TypeScript

Node.js and TypeScript Tutorial: Secure an Express API

Learn how to use TypeScript and Auth0 to secure a feature-complete Express.js API. Tutorial on how to use Auth0 to implement authorization in Express.

January 14, 2021

Starting from this chapter?

Clone the application repo and check out the build-api branch:

git clone git@github.com:auth0-blog/menu-api-express-ts.git \
menu-api \
--branch build-api

Make the project folder your current directory:

cd menu-api

Then, install the project dependencies:

npm i

Finally, create a .env hidden file:

touch .env

Populate .env with this:

PORT=7000

Run the project by executing the following command:

npm run dev

In the Build an API with Node.js and TypeScript Tutorial, you went over how to build an API using Express, a Node.js web framework, and TypeScript, an open-source language that builds on JavaScript. You learned how to define data models, create a data service, and quickly build modular endpoints.

Now, this tutorial will show you how to secure the API using Auth0. To see your API in action, you'll use a production client called "WHATABYTE Dashboard," which is inspired by the sleek web player from Spotify:

WHATBYTE Dashboard demo client

One of the requirements for this project is that only authorized users can write records to the store. To quickly and securely achieve that, you can use Auth0 to manage your application's user credentials.

Set Up an Authorization Service

With Auth0, you can manage the authorization requirements of an application stack easily. To start, you need to create a free Auth0 account if you don't have one yet.

Auth0 is a flexible, drop-in solution to add authentication and authorization services to your applications. Your team and organization can avoid the cost, time, and risk that comes with building your own solution to authenticate and authorize users. We offer tons of guidance and SDKs for you to get started and integrate Auth0 in your stack easily.

After you create your account, you'll create an Auth0 Tenant, which is a container that Auth0 uses to store your identity service configuration and your users in isolation — no other Auth0 customer can peek into or access your tenant. It's similar to you being a tenant in an apartment building. Auth0 looks after the building while the apartment is all yours to live in and customize. However, each apartment is fully isolated (no windows, soundproof walls, etc.) so that neighbors can't intrude on your privacy.

After creating your tenant, you need to create an API register with Auth0, which is an API that you define within your Auth0 tenant and that you can consume from your applications to process authentication and authorization requests.

After creating your account, head to the APIs section in the Auth0 Dashboard and hit the Create API button.

Then, in the form that Auth0 shows:

  • Add a Name to your API: Menu API.

  • Set its Identifier to https://menu-api.example.com.

  • Leave the signing algorithm as RS256 as it's the best option from a security standpoint.

Auth0 Dashboard new API form

Identifiers are unique strings that help Auth0 differentiate between your different APIs. We recommend using URLs as they facilitate predictably creating unique identifiers; however, Auth0 never calls these URLs.

With these values in place, hit the Create button.

Your API needs some configuration variables to identity itself with Auth0: an Audience and a Domain value. The best place to store these values is within the .env file of your project.

Open .env and add the following keys to it:

PORT=7000
AUTH0_AUDIENCE=
AUTH0_DOMAIN=

Head back to your Auth0 API page, and follow these steps to get the Auth0 Audience:

Get the Auth0 Audience to configure an API

  1. Click on the "Settings" tab.

  2. Locate the "Identifier" field and copy its value.

  3. Paste the "Identifier" value as the value of AUTH0_AUDIENCE in .env.

Now, follow these steps to get the Auth0 Domain value:

Get the Auth0 Domain to configure an API

  1. Click on the "Test" tab.
  2. Locate the section called "Asking Auth0 for tokens from my application".
  3. Click on the cURL tab to show a mock POST request.
  4. Copy your Auth0 domain, which is part of the --url parameter value: tenant-name.region.auth0.com.
  5. Paste the Auth0 domain value as the value of AUTH0_DOMAIN in .env.
Tips to get the Auth0 Domain
  • The Auth0 Domain is the substring between the protocol, https:// and the path /oauth/token.

  • The Auth0 Domain follows this pattern: tenant-name.region.auth0.com.

  • The region subdomain (au, us, or eu) is optional. Some Auth0 Domains don't have it.

  • Click on the image above, please, if you have any doubt on how to get the Auth0 Domain value.

Restart the server so that Express can recognize the changes you just made to .env. Stop the running process and execute npm run dev once again.

Create Authentication Middleware

You'll rely on a middleware function to protect an Express API endpoint. Express will execute an authorization middleware function before it executes the callback function of the controller that handles the request.

You can use two patterns to integrate your endpoints with the authorization middleware function.

The first option is to "inject" an authorization middleware function in the controller as follows:

itemsRouter.post(
  "/",
  authorizationFunction,
  async (req: Request, res: Response) => {
    // Controller logic...
  }
);

Here, Express calls authorizationFunction() before the route handler function of itemsRouter.post. In turn, the business logic within authorizationFunction can perform two tasks:

(a) invoke the next function in the middleware chain, the router handler function, if it can determine that the user has the authorization to access the resource or,

(b) close the request-response cycle by responding with a 401 Unauthorized message, which prevents your API from executing the route handler.

The approach of adding authorization middleware by controller gives you granular and low-level control of the authorization flow. However, it can be tedious to inject the authorization middleware function per controller if you have many of them.

As an alternative, you can separate the public controllers from the protected controllers using the authorization middleware as a boundary between groups. For example, within an Express router, you could do the following:

// Public API endpoints

itemsRouter.get(...);

// Protected API endpoints

itemsRouter.use(authorizationFunction);

itemsRouter.post(...);
itemsRouter.put(...);
itemsRouter.delete(...);

As such, client applications can access the GET endpoint without presenting any "proof of authorization" — it is a public endpoint.

However, client requests can only access endpoints that you define after your application mounts authorizationFunction into itemsRouter if authorizationFunction can determine that the client making the endpoint request has the authorization to access it. For this API, Auth0 provides the proof of authorization mentioned in the form of a JSON Web Token (JWT) called an access token.

A JWT defines a compact and self-contained way to transmit information between parties as a JSON object securely. This information can be verified and trusted because it is digitally signed, making JWTs useful to perform authorization.

Once the user logs in using a client application, Auth0 provides the client with an access token that defines the resources that the client has permission to access or manipulate with that token. The access token defines what users can do in your API in the JSON object it encapsulates. As such, the client must include the access token with each subsequent request it makes to a protected API endpoint.

Interested in getting up-to-speed with JWTs as soon as possible?

Download the free ebook
JWT Handbook

You'll use the partition approach for this application as you need to protect all the endpoints that write data to the store.

Install authorization dependencies

To create your authorization middleware function, you need to install these two packages:

npm i express-jwt jwks-rsa

Here's what these packages do for you:

  • express-jwt: Validates the authorization level of HTTP requests using JWT tokens in your Node.js application.

  • jwks-rsa: A library to retrieve RSA signing keys from a JWKS (JSON Web Key Set) endpoint.

Since you are working on a TypeScript project, you also need the type definitions for these packages; however, only the express-jwt package is available in the @types npm namespace:

npm i -D @types/express-jwt

The helper function you need from the jwks-rsa package is simple and doesn't require strong typing.

Next, create a file to define your authorization middleware function:

touch src/middleware/authz.middleware.ts

Populate src/middleware/authz.middleware.ts as follows:

// src/middleware/authz.middleware.ts

import jwt from "express-jwt";
import jwksRsa from "jwks-rsa";
import * as dotenv from "dotenv";

dotenv.config();

export const checkJwt = jwt({
  secret: jwksRsa.expressJwtSecret({
    cache: true,
    rateLimit: true,
    jwksRequestsPerMinute: 5,
    jwksUri: `https://${process.env.AUTH0_DOMAIN}/.well-known/jwks.json`
  }),

  // Validate the audience and the issuer.
  audience: process.env.AUTH0_AUDIENCE,
  issuer: `https://${process.env.AUTH0_DOMAIN}/`,
  algorithms: ["RS256"]
});

When you call the checkJwt function, it invokes the jwt function, verifying that any JSON Web Token (JWT) present in the request payload to authorize the request is well-formed and valid. Auth0 determines the validity of the JWT. As such, you pass the jwt function some variables to help it contact Auth0 and present it with all the JWT information it needs:

  • The audience and issuer of the JWT. You have defined these values in your .env file. Express has loaded into this module using dotenv.config().

  • The algorithms used to sign the JWT.

  • The secret used to sign the JWT.

To obtain the secret, you need to do some additional work: you use the expressJwtSecret helper function from the jwks-rsa library to query the JSON Web Key Set (JWKS) endpoint of your Auth0 tenant. This endpoint has a set of keys containing the public keys that your application can use to verify any JSON Web Token (JWT) issued by the authorization server and signed using the RS256 signing algorithm.

The checkJwt function implicitly receives the request, req, and response, res, object from Express, as well as the next() function, which it can use to invoke the next middleware function in the chain.

All that's left to do is mount the checkJwt middleware function before you mount your itemsRouter write endpoints.

Open src/items/items.router.ts and import checkJwt under the Required External Modules and Interfaces section:

/**
 * Required External Modules and Interfaces
 */

import express, { Request, Response } from "express";
import * as ItemService from "./items.service";
import { BaseItem, Item } from "./item.interface";

import { checkJwt } from "../middleware/authz.middleware";

Then, under the Controller Definitions section, locate the definition of the POST items endpoint, and right above it, add the following code to mount the authorization middleware, itemsRouter.use(checkJwt):

/**
 * Controller Definitions
 */

// GET items
itemsRouter.get(...);

// GET items/:id

itemsRouter.get(...);

// ✨ New! Mount authorization middleware

itemsRouter.use(checkJwt); // 👈 👀

// POST items

itemsRouter.post(...);

// PUT items/:id

itemsRouter.put(...);

// DELETE items/:id

itemsRouter.delete(...);

To test that Express is protecting your write endpoints, issue the following requesting in the terminal:

curl -X POST -H 'Content-Type: application/json' -d '{
  "name": "Salad",
  "price": 499,
  "description": "Fresh",
  "image": "https://cdn.auth0.com/blog/whatabyte/salad-sm.png"
}' http://localhost:7000/api/menu/items -i

The server replies with an HTTP/1.1 401 Unauthorized response status, and the message No authorization token was found, confirming that your write endpoints are protected. To access them, you need a JWT issued by Auth0. The fastest way to get that token is to use a client just like any of your users would.

Register a Client Application with Auth0

You need a client application to simulate an end-user interaction with your API and see its security in action. To make that simulation more fun and engaging, you'll use the WHATABYTE Dashboard, a demo client application that lets you manage items for a restaurant menu. You'll create a user with Auth0, log in, and access pages that make requests to your API endpoints under the hood.

For that end-user interaction to happen, you'll need to create a Single-Page Application register with Auth0. This register will provide you with the configuration values that you need to connect the demo client application with Auth0, namely the Auth0 Domain and Auth0 Client ID. Once configured, the client application can communicate with the Auth0 authentication server and get access tokens for your logged-in users.

The process of creating an Auth0 Single-Page Application register is straightforward:

  • Open the Auth0 Applications section of the Auth0 Dashboard.

  • Click on the Create Application button.

  • Provide a Name value such as WHATABYTE Demo Client.

  • Choose Single Page Web Applications as the application type.

  • Click on the Create button.

A new page loads with details about your Auth0 application register. Click on its Settings tab to access its configuration values.

Next, visit https://dashboard.whatabyte.app/ to open the WHATABYTE Dashboard demo client application.

If you are not on the Auth0 Demo Settings page, click on the "Settings" tab from the left-hand navigation bar and then click the "Modify" button.

Enable the authentication features of the demo application. Then, use the configuration values present in your Auth0 application "Settings" page to fill the values of Auth0 Domain and Auth0 Client ID in the demo settings form:

Auth0 authentication demo settings

For the value of Auth0 API Audience use https://menu-api.example.com, which is the Identifier of the MENU API you registered with Auth0 earlier in the tutorial.

For the value of Auth0 Callback URL use https://dashboard.whatabyte.app/home. You'll learn what how Auth0 uses this callback value in the next section.

Click the Save button below the form. The WHATABYTE Dashboard is a client to your API server. To test this connection, click on the Menu tab and observe how it populates with the menu items you defined in your API store.

Connect a client application with Auth0

Head back to the Settings tab of your Auth0 application register page and update the following fields:

Allowed Callback URLs

Use the value of Auth0 Callback URL from the Auth0 Demo Settings form, https://dashboard.whatabyte.app/home.

After a user authenticates, Auth0 only calls back any of the URLs listed in this field. You can specify multiple valid URLs by comma-separating them (typically to handle different environments like QA or testing). Make sure to specify the protocol, http:// or https://; otherwise, the callback may fail in some cases.

Allowed Web Origins

Use https://dashboard.whatabyte.app.

A client application will make requests under the hood to an Auth0 URL to handle authentication requests. As such, you need to add your the application's origin URL to avoid Cross-Origin Resource Sharing (CORS) issues.

Allowed Logout URLs

Use https://dashboard.whatabyte.app/home.

This field holds a set of URLs that Auth0 can redirect to after a user logs out of your application. The default configuration of the demo client uses the provided value for redirecting users.

With these values in place, you can scroll to the bottom of the "Settings" page and click on the Save Changes button.

Sign In

In the demo client, click on the Sign In button. The client will redirect you to the Auth0 Universal Login page to log in or sign up. Since this may be the first user you are adding to Auth0, go ahead and click on the Sign Up link at the bottom of the form. Then, provide an email and password to register a new user.

Auth0 Universal Login

Once you sign in, the user interface of the demo client changes:

  • The Sign In button becomes a Sign Out button.

  • You can find a user tab below the Sign Out button.

Menu page after user logs in

Click on the user tab to see a profile page with your name or email as the title and your profile picture — if you signed in with Google:

User profile page

The demo client caters to three types of users:

  • Unauthenticated visitors: any visitor who has not logged in — some literature may refer to this type of user as "guest" or "anonymous".

  • Authenticated users: any visitor who successfully logs in.

  • Admin users: any authenticated user with the menu-admin role.

The end-goal of this tutorial is to use the menu-admin role and its associated permissions as access control artifacts. The plan is to only allow admin users to create, update, and delete menu items in the WHATABYTE Dashboard. In the Role-Based Access Control (RBAC) section of this tutorial, you'll create the menu-admin role, associate permissions with it, and assign it to a new user that you'll create through the Auth0 Dashboard.

However, you'll start with protecting your API write endpoints against unauthenticated visitors.

Experiment with the demo client:

  • Add items by clicking on the Add Item button located at the top-right corner of the "Menu" page.

  • Click on items and try to edit them or delete them.

You can do any read or write operations right now.

Security Exercise: Test your endpoint protection

Log out from the demo application.

Click on the Settings tab on the left-hand navigation bar of the demo client. Then, click on the Modify button.

The "Auth0 Demo Settings" page loads up. Disable the authentication features:

Dashboard demo settings without authentication

Click on the Save button.

Once the demo application loads again, click on the Menu tab. You'll notice that the Add Item button is now visible. In this mode, the demo client lets you access UI elements that make requests to your API write endpoints as an unauthenticated visitor. As such, those requests won't include an access token. If your API security is working correctly, it should reject those requests.

Click on the Add Item button to open a pre-populated form and click on the Save button. You'll get an error, No authorization token was found:

Unauthorized error message when creating an item as an unauthenticated visitor

Success! Your Express API server is effectively guarding your write endpoints against unauthorized requests. In this context, only authenticated users are authorized to access the API write endpoints.

Click on the Cancel button in the "Add Menu Item" page. The "Menu Items" loads again. Click on the "Burger" item and try to edit it or delete it.

Those two actions will also fail:

Unauthorized error message when updating an item as an unauthenticated visitor

Unauthorized error message when deleting an item as an unauthenticated visitor

You have tested that Express is guarding your create, update, and delete endpoints correctly, concluding this short exercise.

To continue with the rest of this tutorial, re-enable the demo client authentication features. Click on the Settings tab and click on the Modify button. The "Auth0 Demo Settings" page loads up. Enable the authentication features, fill out the necessary value, and click on the Save button.

Configure Role-Based Access Control (RBAC)

Your API server is protecting your write endpoints, but any authenticated user can modify the menu items. This scenario is far from ideal as you don't want regular customers to change an item's price, for example. As such, you need a mechanism to limit access to your API resources and demonstrate that being authenticated is not the same as being authorized. However, if all that you want for now is to restrict API access to logged-in users, you've achieved that, and you are done.

A straightforward way to implement access control is to create a set of write permissions and bundle them in a menu-admin role, which you assign only to select users. Thus, only select users are authorized to modify resources in your API. Consequently, your server must enforce role verification on each API write endpoint to prevent unauthorized access.

To re-iterate: being authenticated won't be enough to being authorized to write data to the store.

The practice described above is known as Role-Based Access Control (RBAC), which you can implement quickly for your API using the Auth0 Dashboard. You can implement RBAC and enforce it on your server as follows:

On the Auth0 side

  • Create permissions for the Menu API you created earlier.

  • Create a role called menu-admin.

  • Assign permissions from the Menu API to the menu-admin role.

  • Assign the menu-admin role to a user.

  • Add the menu-admin role permissions to the access token created for users with the role when they sign in.

On the server side

  • Define the menu-admin role permissions in a TypeScript enum.

  • Define the permissions required to access an endpoint by passing permission values as arguments to an authorization middleware function, which Express calls before the endpoint route handler.

  • Implement the authorization middleware function to determine authorization by comparing the permissions required by the endpoint against the permissions present in a user's access token.

As you can see, implementing authorization is a complex process that involves many steps. Any errors or omissions in the process of implementation can leave an API at risk of being compromised. Thankfully, you can delegate the creation and maintenance of permissions, roles, and users to Auth0 and focus only on enforcing authorization on your server.

With the plan clearly outlined, let's get started.

Define permissions for the API

Open the APIs page from the Auth0 Dashboard and select the Menu API that you created earlier.

In the Menu API page, click on the Permissions tab and create three permissions by filling each row as follows (the + Add button adds a new row):

  • create:items: Create menu items

  • update:items: Update menu items

  • delete:items: Delete menu items

Next, you need to configure Auth0 to enforce role-based access control (RBAC) authorization for the Menu API. Click on the Settings tab and scroll down until you see the RBAC Settings section. Use the toggle button next to Enable RBAC to turn it on, which enforces Auth0 to evaluate RBAC authorization policies during the login transaction of a user.

Next, enable Add Permissions in the Access Token to add a permissions property to the access token created by Auth0 when a user logs in. The permissions property is a key-value pair known as a token claim. The presence of this claim is critical for the implementation of RBAC in your API server.

Once you enable these options, make sure to click on the Save button.

Create roles

Open the Roles page from the Auth0 Dashboard and click on the Create Role button. Fill out the pop-up form as follows:

  • Name: menu-admin

  • Description: Create, update, and delete menu items.

Once done, click the Create button to complete the creation of the role.

Now, you need to associate the permissions you've created with this role, mapping it to your API's resources. Click on the Permissions tab of the role page. Once there, click on the Add Permissions button.

In the dialog that comes up, choose the Menu API from the dropdown box and select all the boxes in the Scopes section. Once that's done, click on the Add permissions button. You are back to the menu-admin role page, which now lists all its associated permissions.

A scope is a term used by the OAuth 2.0 protocol to define limitations on the amount of access that you can grant to an access token. In essence, permissions define the scope of an access token.

Get user roles

Auth0 attaches the menu-admin role permissions as a claim to the access token, but not the role itself. The demo client application needs this information as it renders its UI conditionally based on the user role. To include the user role as a claim in the tokens that Auth0 sends to the client, you can use Auth0 Rules.

When a user logs in successfully to your application, the Auth0 authorization server sends two tokens to the client:

Access token

After a user successfully authenticates and authorizes access, the client application receives an access token from the Auth0 authentication server. The client passes the access token as a credential whenever it calls a protected endpoint of the target API. This token informs the server that the client is authorized to access the API. Through its permissions claim, the access token tells the server which actions the client can perform on which resources.

ID token

The ID Token is a JSON Web Token (JWT) that contains claims representing user profile attributes like name or email, which are values that clients typically use to customize the UI.

Using Auth0 Rules, you can add to each of these tokens a new claim, representing the roles assigned to a user.

What are Auth0 Rules?

Auth0 Rules are JavaScript functions that execute when a user logs in to your application. They run once the authentication process is complete, and you can use them to customize and extend Auth0's capabilities. For security, your Rules code executes in a sandbox, isolated from the code of other Auth0 tenants.

You can create Auth0 Rules easily using the Auth0 Dashboard. Follow these steps to create a rule that adds user roles to tokens:

  • Open the Rules page from the Auth0 Dashboard.

  • Click on the Create Rule button.

  • Click on the Empty Rule option.

  • Provide a Name to your rule, such as "Add user roles to tokens".

  • Next, replace the content of the Script section with the following function:

function(user, context, callback) {
  const namespace = 'https://menu-api.example.com';

  if (context.authorization && context.authorization.roles) {
    const assignedRoles = context.authorization.roles;

    if (context.idToken) {
      const idTokenClaims = context.idToken;
      idTokenClaims[`${namespace}/roles`] = assignedRoles;
      context.idToken = idTokenClaims;
    }

    if (context.accessToken) {
      const accessTokenClaims = context.accessToken;
      accessTokenClaims[`${namespace}/roles`] = assignedRoles;
      context.accessToken = accessTokenClaims;
    }
  }

  callback(null, user, context);
}
  • Click the Save Changes button.
What's this rule doing?

When the user successfully authenticates, this rule function executes, receiving three parameters:

  • user: an object returned by the identity provider (such as Auth0 or Google) that represents the logged-in user.

  • context: an object that stores contextual information about the current authentication transaction, such as the user's IP address or location.

  • callback: a function to send modified tokens or an error back to Auth0. You must call this function to prevent script timeouts.

function(user, context, callback) {
  // ...
}

To keep your custom claims from colliding with any reserved or external claims, you must give them a globally unique name using a namespaced format. By default, Auth0 always enforces namespacing and silently excludes from the tokens any custom claims with non-namespaced identifiers.

Namespaces are arbitrary identifiers, so technically, you can call your namespace anything you want. For convenience, the namespace value is the API audience value set in the WHATABYTE Dashboard Demo Settings.

function(user, context, callback) {
  const namespace = 'https://menu-api.example.com';

  //...
}

You then check if the context object has an authorization property and, in turn, if that property has a roles property:

function(user, context, callback) {
  const namespace = 'https://menu-api.example.com';

  if (context.authorization && context.authorization.roles) {
   // ...
  }

  // ...
}

context.authorization is an object containing information related to the authorization transaction, such as roles.

context.authorization.roles is an array of strings containing the names of the roles assigned to a user.

Next, you assign the roles array to the assignedRoles constant and check if there is an ID token or access token present in the context object:

function(user, context, callback) {
  const namespace = 'https://menu-api.example.com';

  if (context.authorization && context.authorization.roles) {
    const assignedRoles = context.authorization.roles;

    if (context.idToken) {
      // ...
    }

    if (context.accessToken) {
      // ...
    }
  }

  // ...
}

If any of these tokens are present, you add to the token object a <namespace>/roles property with the roles array, assignedRoles, as its value, effectively creating a custom claim on the token that represents the user roles:

function(user, context, callback) {
  const namespace = 'https://menu-api.example.com';

  if (context.authorization && context.authorization.roles) {
    const assignedRoles = context.authorization.roles;

    if (context.idToken) {
      const idTokenClaims = context.idToken;
      idTokenClaims[`${namespace}/roles`] = assignedRoles;
      context.idToken = idTokenClaims;
    }

    if (context.accessToken) {
      const accessTokenClaims = context.accessToken;
      accessTokenClaims[`${namespace}/roles`] = assignedRoles;
      context.accessToken = accessTokenClaims;
    }
  }

  // ...
}

Finally, you invoke the callback function to send the potentially modified tokens back to Auth0, which in turn sends them to the client:

function(user, context, callback) {
  // ...

  callback(null, user, context);
}

That's all you need to create an Auth0 rule that adds user roles to tokens. What's left to do is for you to create a user that has the menu-admin role.

Before you do that, verify how the user interface restricts access to certain user interface elements and views when a user doesn't have the menu-admin role.

Head back to the demo client.

Next, click on the "Settings" tab from the left-hand navigation bar and click the "Modify" button to change the demo settings.

The "Auth0 Demo Settings" view loads up. Enable Role-Based Access Control (RBAC), which reveals the User Role field. Populate that field with the following value: menu-admin.

Adding a user role in the demo settings

Once you set that value, leave every other field as it is. Then, click on the Save button.

Once you are back to the application, sign in. Notice how the Add Item button is no longer visible in the "Menu Items" page. If you click on a menu item, you won't see the Edit or Delete buttons either.

You need to grant yourself or any other user you create admin access!

Create an admin user

Open the Users page from the Auth0 Dashboard and click on Create User. Fill the form that pops up with the following:

  • Email: admin@example.com

  • Password and Repeat Password: Any password of your choice

  • Connection: Username-Password-Authentication

Click on the Create button. The page of the admin@example.com user loads up. On this page, click on the "Roles" tab and then click on the Assign Roles button.

From the dropdown, select the menu-admin role that you created earlier and click on the Assign button. Verify that the user has the permissions by clicking on the "Permissions" tab. If so, your admin user is all set up and ready to use.

As an alternative, you may assign the menu-admin role to the existing user you have been using to access the demo application.

Sign In as Admin

Head back to the demo client and sign out.

Click on the Sign In button again and, this time, login in as the admin@example.com user or as any user that you have granted the menu-admin role.

This time around, the UI unlocks admin features. Open the "Menu" page and notice the "Add Item" button is back at the top-right corner. Click on a menu item and notice how you can now edit or delete the item.

However, at this moment, non-admin users could circumvent the client-side route protections to unlock the admin features of the UI. Additionally, they could extract the access token sent by Auth0 using the browser's developer tools and make requests directly to the server write endpoints using the terminal, for example.

Your server needs to implement role-based access control to mitigate these attack vectors.

Implement Role-Based Access Control

To implement role-based access control (RBAC) in Express, you create an RBAC middleware function that inspects the access token provided in the client request and verifies that it has the permissions required by the endpoint it needs to access. You also must call this function before your application reaches the route handler function of the protected endpoint controller.

Consequently, if the proper permissions are present in the access token, your RBAC middleware function calls the next middleware function in the chain, effectively granting the client request with access to the protected endpoint. Otherwise, your application terminates the request-response cycle and sends a response with a 403 Forbidden status code to the client.

To help you inspect the access token for permissions easily, you will use the express-jwt-authz package:

npm install express-jwt-authz

Using this package, you will inspect the permissions claim of the access token to determine if the client making a request to a protected endpoint has all the permissions required.

To start, create a file to define a middleware function that checks for permissions:

touch src/middleware/permissions.middleware.ts

Then, populate src/middleware/permissions.middleware.ts with the following code:

const jwtAuthz = require("express-jwt-authz");

export const checkPermissions = (permissions: string | string[]) => {
  return jwtAuthz([permissions], {
    customScopeKey: "permissions",
    checkAllScopes: true,
    failWithError: true
  });
};

The jwtAuthz function takes as a first argument an array of strings representing the permissions required by an endpoint. Its second argument is an optional configuration object. You can configure how jwtAuthz should behave by specifying different options as follows:

  • customScopeKey: By default, jwtAuthz checks permissions against the scope claim of the access token. You can use this option to change the claim jwtAuthz should use. In this case, you specify that the access token stores permission-related data in a claim called permissions.

  • checkAllScopes: When set to true, all the expected permissions by the endpoint must be present in the customScopeKey claim of the access token. If any permission is missing, this middleware function throws an error, which effectively denies the client application making the request from accessing the protected endpoint.

  • failWithError: When set to true, jwtAuthz will forward any errors to the next() middleware function instead of ending the response directly. In this case, jwtAuthz will forward the error to your errorHandler middleware function, where you can better customize the error response sent to the client.

jwtAuthz is a fully-defined and self-contained middleware function, which means it is a function that has access to the Request object, the Response object, and the next middleware function in the application’s request-response cycle. As such, you can technically avoid creating the checkPermissions helper function and invoke the jwtAuthz function directly on each endpoint as follows:

itemsRouter.post(
  "/",
  [
    checkJwt,
    jwtAuthz([ItemPermissions.CreateItems], {
      customScopeKey: "permissions",
      checkAllScopes: true,
      failWithError: true
    })
  ],
  async (req: Request, res: Response) => {
    // function body...
  }
);

itemsRouter.put(
  "/",
  [
    checkJwt,
    jwtAuthz([ItemPermissions.UpdateItems], {
      customScopeKey: "permissions",
      checkAllScopes: true,
      failWithError: true
    })
  ],
  async (req: Request, res: Response) => {
    // function body...
  }
);

However, this requires you to repeatedly configure jwtAuthz at each endpoint. Instead, you use a JavaScript closure to create a re-usable functional wrapper for jwtAuthz. The checkPermissions helper function takes as arguments the permissions required and creates a closure around that value within its body. It then returns an instance of jwtAuthz, which can access the value of permissions when Express executes it. As such, you only need to configure jwtAuthz in a single place, making your code much more maintainable and less error-prone. You'll apply this approach to the endpoints.

With the RBAC authorization middleware function created, you are now ready to wire it into any controller that needs role-based access control (RBAC).

Define permissions locally

To make it easy to manage and use permissions in your code, you can define them using a TypeScript enum. Under the src/items directory, create the following file:

touch src/items/item-permission.ts

Populate src/items/item-permission.ts as follows:

export enum ItemPermission {
  CreateItems = "create:items",
  UpdateItems = "update:items",
  DeleteItems = "delete:items",
}

A TypeScript enum lets you define a set of named constants, which documents what these constants do while also preventing you from introducing bugs in your application by mistyping them. Each constant represents one of the permissions that you created in the Auth0 dashboard.

Apply permissions middleware

To protect the write endpoints with RBAC, you need to inject the checkPermissions middleware function into a controller definition and pass it the permissions that the endpoint requires as arguments.

Open src/items/items.router.ts, locate the Required External Modules and Interfaces section, and add the following imports:

/**
 * Required External Modules and Interfaces
 */

import express, { Request, Response } from "express";
import * as ItemService from "./items.service";
import { BaseItem, Item } from "./item.interface";

import { checkJwt } from "../middleware/authz.middleware";
import { checkPermissions } from "../middleware/permissions.middleware";
import { ItemPermission } from "./item-permission";

Next, locate the Controller Definitions section and update the following controller definitions:

/**
 * Controller Definitions
 */

// GET items/ ...

// GET items/:id ...

// Mount authorization middleware

itemsRouter.use(checkJwt);

// POST items

itemsRouter.post(
  "/",
  checkPermissions(ItemPermission.CreateItems),
  async (req: Request, res: Response) => {
    try {
      const item: BaseItem = req.body;

      const newItem = await ItemService.create(item);

      res.status(201).json(newItem);
    } catch (e) {
      res.status(500).send(e.message);
    }
  }
);

// PUT items/:id

itemsRouter.put(
  "/:id",
  checkPermissions(ItemPermission.UpdateItems),
  async (req: Request, res: Response) => {
    const id: number = parseInt(req.params.id, 10);

    try {
      const itemUpdate: Item = req.body;

      const existingItem: Item = await ItemService.find(id);

      if (existingItem) {
        const updatedItem = await ItemService.update(id, itemUpdate);
        return res.status(200).json(updatedItem);
      }

      const newItem = await ItemService.create(itemUpdate);

      res.status(201).json(newItem);
    } catch (e) {
      res.status(500).send(e.message);
    }
  }
);

// DELETE items/:id

itemsRouter.delete(
  "/:id",
  checkPermissions(ItemPermission.DeleteItems),
  async (req: Request, res: Response) => {
    try {
      const id: number = parseInt(req.params.id, 10);
      await ItemService.remove(id);

      res.sendStatus(204);
    } catch (e) {
      res.status(500).send(e.message);
    }
  }
);

Now that the authorization guards are in place, any attempt to create a new menu item directly using a non-admin access token results in failure:

  • The checkPermissions() function injected in the POST api/menu/items/ endpoint detects the presence of the required permissions.

  • If the required permissions are missing, it then passes a 403 exception down the middleware chain.

  • As such, you successfully prevent the application from invoking the route handler of this endpoint.

Remember that by mounting the checkJwt() middleware function as a router-level middleware, you don't have to include it at the endpoint-level. With just one line of code, itemsRouter.use(checkJwt), you are protecting all the endpoints that follow it against invalid access tokens. By subsequently applying the checkPermissions() middleware at the route-controller-level, you protect that route from authenticated requests that lack the required access level.

You are effectively implementing Role-Based Access Control (RBAC) using a two-layer approach powered by Express middleware functions.

Sign out and sign back in as the admin user in the demo client. Try to add a new item. The "Add Item" page has a form pre-loaded with some data to make this process easier for you. If you already created the salad item, try to create a coffee item with this data:

name: Coffee
price: 299
description: Woke
image: https://images.ctfassets.net/23aumh6u8s0i/6HS0xLG6bx52KJrqyqfznk/50f9350a7791fa86003024af4762f4ca/whatabyte_coffee-sm.png

Click on that newly created item and notice that you can either edit or delete it. Try both operations.

Menu page showing a newly added menu item, coffee

Security Exercise: Remove the Admin Role

Log out from the demo application.

Click on the Settings tab on the left-hand navigation bar of the demo client. Then, click on the Modify button.

The "Auth0 Demo Settings" page loads up. Delete the value of User Role, leave it blank, then click the Save button.

Now, either:

(a) sign in as a non-admin user, or

(b) remove the menu-admin role from your current user in the Auth0 Dashboard and sign in as that user.

You'll have access to the admin UI elements. Click on the "Tea" item and try to delete it. You'll get an error message, Insufficient scope:

Delete Menu Item page showing an error message, insufficient scope

This error message is telling you that you don't have enough permissions to perform that action. If you inspect the "Network" or "Console" tab of your browser's developer tools, you'll notice that your Express API server replied with a 403 (Forbidden) error.

You'll get the same type of error if you try to add or edit an item. You have confirmed that your Express API server is effectively guarding your write endpoints from unauthenticated users and from authenticated users who lack the permissions to access them.

Click on the Settings tab on the left-hand navigation and click on the Modify button. Restore the value of User Role back to menu-admin and save your changes. If you removed the menu-admin role from a user, head back to the Auth0 Dashboard and give back the role to the user.

What's Next

You have implemented authorization in Express to control the resources that your users can access. You have learned how to implement different access levels:

  • Access based on authentication status.
    • If you have logged in, you are authorized to access the resources.
  • Access based on permissions.
    • If you have logged in and have the required permissions, you are authorized to access the resources.

      This tutorial covered the most common authorization use cases for an Express API server. However, Auth0 is an extensible and flexible platform that can help you achieve even more. If you have a more complex use case, check out the [Auth0 Architecture Scenarios](https://auth0.com/docs/architecture-scenarios) to learn more about the typical architecture scenarios we have identified when working with customers on implementing Auth0.

Let me know in the comments below what you thought of this tutorial. Thank you for reading this far, and happy engineering!

I have feedback or ran into an issue

  • Twitter icon
  • LinkedIn icon
  • Faceboook icon