Technology25+ minute read

How to Use JWT and Node.js for Better App Security

To protect client data, your system must identify and block uninvited visitors. Create a REST API using Node.js, TypeScript, and Express, enhanced with JWT support.


Toptalauthors are vetted experts in their fields and write on topics in which they have demonstrated experience. All of our content is peer reviewed and validated by Toptal experts in the same field.

To protect client data, your system must identify and block uninvited visitors. Create a REST API using Node.js, TypeScript, and Express, enhanced with JWT support.


Toptalauthors are vetted experts in their fields and write on topics in which they have demonstrated experience. All of our content is peer reviewed and validated by Toptal experts in the same field.
Gonzalo Hirsch's profile image

Gonzalo Hirsch

Gonzalo is a full-stack developer and expert in secure implementations focused on JavaScript and Node.js. He specializes in solutions for startup companies in finance and education, and has a master’s degree in software engineering from the Instituto Tecnológico de Buenos Aires.

Previously At

Croud
Share

To protect proprietary data, it is imperative to secure any API that provides services to clients through requests. A well-built API identifies intruders and prevents them from gaining access, and a JSON Web Token (JWT) allows client requests to be validated and potentially encrypted.

In this tutorial, we will demonstrate the process of adding JWT security to a Node.js API implementation. While there are multiple ways to implement API layer security, JWT is a widely adopted, developer-friendly security implementation in Node.js API projects.

JWT Explained

JWT is an open standard that safely allows information exchange in a space-constrained environment using a JSON format. It’s simple and compact, enabling a broad range of applications that elegantly combine a number of other security standards.

JWTs, carrying our encoded data, may be encrypted and concealed, or signed and easily readable. If a token is encrypted, all required hash and algorithmic information is contained in it to support its decryption. If a token is signed, its recipient will analyze the JWT’s contents and should be able to detect whether it has been tampered with. Tamper detection is supported through JSON Web Signature (JWS), the most commonly used signed token approach.

JWT consists of three major parts, each composed of a name-value pair collection:

We define JWT’s header using the JOSE standard to specify the token’s type and cryptographic information. The required name-value pairs are:

Name

Value Description

typ

Content type ("JWT" in our case)

alg

Token-signing algorithm, chosen from the JSON Web Algorithms (JWA) list

JWS signatures support both symmetric and asymmetric algorithms to provide token tamper detection. (Additional header name-value pairs are required and specified by the various algorithms, but a full exploration of those header names is beyond the scope of this article.)

Payload

JWT’s required payload is the encoded (potentially encrypted) content that one party may send to another. A payload is a set of claims, each represented by a name-value pair. These claims are the meaningful portion of a message’s transmitted data (i.e., not including the message header and metadata). The payload is enclosed in a secure communication, sealed with our token’s signature.

Each claim may use a name that originates in the JWT’s reserved set, or we may define a name ourselves. If we define a claim name ourselves, best practices dictate to steer clear of any name listed in the following reserved word list, to avoid any confusion.

Specific reserved names must be included in the payload regardless of any additional claims present:

Name

Value Description

aud

A token’s audience or recipient

sub

A token’s subject, a unique identifier for whichever programmatic entity is referenced within the token (e.g., a user ID)

iss

A token’s issuer ID

iat

A token’s “issued at” time stamp

nbf

A token’s “not before” time stamp; the token is rendered invalid before said time

exp

A token’s “expiration” time stamp; the token is rendered invalid at said time

Signature

To securely implement JWT, a signature (i.e., JWS) is recommended for use by an intended token recipient. A signature is a simple, URL-safe, base64-encoded string that verifies a token’s authenticity.

The signature function is dependent on the header-specified algorithm. The header and payload parts are both passed to the algorithm, as follows:

base64_url(fn_signature(base64_url(header)+base64_url(payload)))

Any party, including the recipient, may independently run this signature calculation to compare it to the JWT signature from within the token to see whether the signatures match.

While a token with sensitive data should be encrypted (i.e., using JWE), if our token does not contain sensitive data, it is acceptable to use JWS for nonencrypted and therefore public, yet encoded, payload claims. JWS allows our signature to contain information enabling our token’s recipient to determine if the token has been modified, and thus corrupted, by a third party.

Common JWT Use Cases

With JWT’s structure and intent explained, let’s explore the reasons to use it. Though there is a broad spectrum of JWT use cases, we’ll focus on the most common scenarios.

API Authentication

When a client authenticates with our API, a JWT is returned—this use case is common in e-commerce applications. The client then passes this token to each subsequent API call. The API layer will validate the authorization token, verifying that the call may proceed. Clients may access an API’s routes, services, and resources as appropriate for the authenticated client’s level.

Federated Identity

JWT is commonly used within a federated identity ecosystem, in which users’ identities are linked across multiple separate systems, such as a third-party website that uses Gmail for its login. A centralized authentication system is responsible for validating a client’s identity and producing a JWT for use with any API or service connected to the federated identity.

Whereas nonfederated API tokens are straightforward, federated identity systems typically work with two token types: access tokens and refresh tokens. An access token is short-lived; during its period of validity, an access token authorizes access to a protected resource. Refresh tokens are long-lived and allow a client to request new access tokens from authorization servers with no requirement that client credentials be re-entered.

Stateless Sessions

Stateless session authentication is similar to API authentication, but with more information packed into a JWT and passed along to an API with each request. A stateless session mainly involves client-side data; for example, an e-commerce application that authenticates its shoppers and stores their shopping cart items might store them using a JWT.

In this use case, the server avoids storing a per-user state, limiting its operations to using only the information passed to it. Having a stateless session on the server side involves storing more information on the client side, and thus requires the JWT to include information about the user’s interaction, such as a cart or the URL to which it will redirect. This is why a stateless session’s JWT includes more information than a comparable stateful session’s JWT.

JWT Security Best Practices

To avoid common attack vectors, it is imperative to follow JWT best practices:

Best Practice

Details

Always perform algorithm validation.

Trusting unsecured tokens leaves us vulnerable to attacks. Avoid trusting security libraries to autodetect the JWT algorithm; instead, explicitly set the validation code’s algorithm.

Select algorithms and validate cryptographic inputs.

JWA defines a set of acceptable algorithms and the required inputs for each. Shared secrets for symmetric algorithms should be long, complex, random, and need not be human friendly.

Validate all claims.

Tokens should only be considered valid when both the signature and the contents are valid. Tokens passed between parties should use a consistent set of claims.

Use the typ claim to separate token types.

When multiple token types are used, the system must verify that each token type is correctly handled. Each token type should have its own clear validation rules.

Require transport security.

Use transport layer security (TLS) when possible to mitigate different- or same-recipient attacks. TLS prevents a third party from accessing an in-transit token.

Rely on trusted JWT implementations.

Avoid custom implementations. Use the most tested libraries and read a library’s documentation to understand how it works.

Generate a unique sub representation without exposing implementation details or personal information.

From a security standpoint, storing information that directly or indirectly points to a user (e.g., email address, user ID) within the system is inadvisable. Regardless, given that the sub claim is used to identify the token’s subject, we must equip it with a reference of some sort so that the token will work. To minimize information exposure via the token, a one-way encryption algorithm and checksum function can be implemented together and sent as the sub claim.

With these best practices in mind, let’s move to a practical implementation of creating a JWT and Node.js example, in which we put these points into use. At a high level, we’re going to create a new project in which we’ll authenticate and authorize our endpoints with JWT, following three major steps.

We will use Express because it offers a quick way to create back-end applications at both enterprise and hobby levels, making the integration of a JWT security layer simple and straightforward. And we’ll go with Postman for testing since it allows for effective collaboration with other developers to standardize end-to-end testing.

The final, ready-to-deploy version of the full project repository is available as a reference while walking through the project.

Step 1: Create the Node.js API

Create the project folder and initialize the Node.js project:

mkdir jwt-nodejs-security
cd jwt-nodejs-security
npm init -y

Next, add project dependencies and generate a basic tsconfig file (which we will not edit during this tutorial), required for TypeScript:

npm install typescript ts-node-dev @types/bcrypt @types/express --save-dev
npm install bcrypt body-parser dotenv express
npx tsc --init

With the project folder and dependencies in place, we’ll now define our API project.

Configuring the API Environment

The project will use system environment values within our code. Let’s first create a new configuration file, src/config/index.ts, that retrieves environment variables from the operating system, making them available to our code:

import * as dotenv from 'dotenv';
dotenv.config();

// Create a configuration object to hold those environment variables.
const config = {
    // JWT important variables
    jwt: {
        // The secret is used to sign and validate signatures.
        secret: process.env.JWT_SECRET,
        // The audience and issuer are used for validation purposes.
        audience: process.env.JWT_AUDIENCE,
        issuer: process.env.JWT_ISSUER
    },
    // The basic API port and prefix configuration values are:
    port: process.env.PORT || 3000,
    prefix: process.env.API_PREFIX || 'api'
};

// Make our confirmation object available to the rest of our code.
export default config;

The dotenv library allows environment variables to be set in either the operating system or within an .env file. We’ll use an .env file to define the following values:

  • JWT_SECRET
  • JWT_AUDIENCE
  • JWT_ISSUER
  • PORT
  • API_PREFIX

Your .env file should look something like the repository example. With the basic API configuration complete, we now move to coding our API’s storage.

Setting Up In-memory Storage

To avoid the complexities that come with a fully fledged database, we’ll store our data locally in the server state. Let’s create a TypeScript file, src/state/users.ts, to contain the storage and CRUD operations for API user information:

import bcrypt from 'bcrypt';
import { NotFoundError } from '../exceptions/notFoundError';
import { ClientError } from '../exceptions/clientError';

// Define the code interface for user objects. 
export interface IUser {
    id: string;
    username: string;
    // The password is marked as optional to allow us to return this structure 
    // without a password value. We'll validate that it is not empty when creating a user.
    password?: string;
    role: Roles;
}

// Our API supports both an admin and regular user, as defined by a role.
export enum Roles {
    ADMIN = 'ADMIN',
    USER = 'USER'
}

// Let's initialize our example API with some user records.
// NOTE: We generate passwords using the Node.js CLI with this command:
// "await require('bcrypt').hash('PASSWORD_TO_HASH', 12)"
let users: { [id: string]: IUser } = {
    '0': {
        id: '0',
        username: 'testuser1',
        // Plaintext password: testuser1_password
        password: '$2b$12$ov6s318JKzBIkMdSMvHKdeTMHSYMqYxCI86xSHL9Q1gyUpwd66Q2e', 
        role: Roles.USER
    },
    '1': {
        id: '1',
        username: 'testuser2',
        // Plaintext password: testuser2_password
        password: '$2b$12$63l0Br1wIniFBFUnHaoeW.55yh8.a3QcpCy7hYt9sfaIDg.rnTAPC', 
        role: Roles.USER
    },
    '2': {
        id: '2',
        username: 'testuser3',
        // Plaintext password: testuser3_password
        password: '$2b$12$fTu/nKtkTsNO91tM7wd5yO6LyY1HpyMlmVUE9SM97IBg8eLMqw4mu',
        role: Roles.USER
    },
    '3': {
        id: '3',
        username: 'testadmin1',
        // Plaintext password: testadmin1_password
        password: '$2b$12$tuzkBzJWCEqN1DemuFjRuuEs4z3z2a3S5K0fRukob/E959dPYLE3i',
        role: Roles.ADMIN
    },
    '4': {
        id: '4',
        username: 'testadmin2',
        // Plaintext password: testadmin2_password
        password: '$2b$12$.dN3BgEeR0YdWMFv4z0pZOXOWfQUijnncXGz.3YOycHSAECzXQLdq',
        role: Roles.ADMIN
    }
};

let nextUserId = Object.keys(users).length;

Before we implement specific API routing and handler functions, let’s focus on error-handling support for our project to propagate JWT best practices throughout our project code.

Adding Custom Error Handling

Express does not support proper error handling with asynchronous handlers, as it doesn’t catch promise rejections from within asynchronous handlers. To catch such rejections, we need to implement an error-handling wrapper function.

Let’s create a new file, src/middleware/asyncHandler.ts:

import { NextFunction, Request, Response } from 'express';

/**
 * Async handler to wrap the API routes, allowing for async error handling.
 * @param fn Function to call for the API endpoint
 * @returns Promise with a catch statement
 */
export const asyncHandler = (fn: (req: Request, res: Response, next: NextFunction) => void) => (req: Request, res: Response, next: NextFunction) => {
    return Promise.resolve(fn(req, res, next)).catch(next);
};

The asyncHandler function wraps API routes and propagates promise errors into an error handler. Before we code the error handler, we’ll define some custom exceptions in src/exceptions/customError.ts for use in our application:

// Note: Our custom error extends from Error, so we can throw this error as an exception.
export class CustomError extends Error {
    message!: string;
    status!: number;
    additionalInfo!: any;

    constructor(message: string, status: number = 500, additionalInfo: any = undefined) {
        super(message);
        this.message = message;
        this.status = status;
        this.additionalInfo = additionalInfo;
    }
};

export interface IResponseError {
    message: string;
    additionalInfo?: string;
}

Now we create our error handler in the file src/middleware/errorHandler.ts:

import { Request, Response, NextFunction } from 'express';
import { CustomError, IResponseError } from '../exceptions/customError';

export function errorHandler(err: any, req: Request, res: Response, next: NextFunction) {
    console.error(err);
    if (!(err instanceof CustomError)) {
        res.status(500).send(
            JSON.stringify({
                message: 'Server error, please try again later'
            })
        );
    } else {
        const customError = err as CustomError;
        let response = {
            message: customError.message
        } as IResponseError;
        // Check if there is more info to return.
        if (customError.additionalInfo) response.additionalInfo = customError.additionalInfo;
        res.status(customError.status).type('json').send(JSON.stringify(response));
    }
}

We have already implemented general error handling for our API, but we also want to support throwing rich errors from within our API handlers. Let’s define those rich error utility functions now, with each one defined in a separate file:

src/exceptions/clientError.ts: Handles status code 400 errors.

import { CustomError } from './customError';

export class ClientError extends CustomError {
    constructor(message: string) {
        super(message, 400);
    }
}

src/exceptions/unauthorizedError.ts: Handles status code 401 errors.

import { CustomError } from './customError';

export class UnauthorizedError extends CustomError {
    constructor(message: string) {
        super(message, 401);
    }
}

src/exceptions/forbiddenError.ts: Handles status code 403 errors.

import { CustomError } from './customError';

export class ForbiddenError extends CustomError {
    constructor(message: string) {
        super(message, 403);
    }
}

src/exceptions/notFoundError.ts: Handles status code 404 errors.

import { CustomError } from './customError';

export class NotFoundError extends CustomError {
    constructor(message: string) {
        super(message, 404);
    }
}

With the basic project and error-handling functions implemented, let’s define our API endpoints and their handler functions.

Defining Our API Endpoints

Let’s create a new file, src/index.ts, to define our API’s entry point:

import express from 'express';
import { json } from 'body-parser';
import { errorHandler } from './middleware/errorHandler';
import config from './config';

// Instantiate an Express object.
const app = express();
app.use(json());

// Add error handling as the last middleware, just prior to our app.listen call.
// This ensures that all errors are always handled.
app.use(errorHandler);

// Have our API listen on the configured port.
app.listen(config.port, () => {
    console.log(`server is listening on port ${config.port}`);
});

We need to update the npm-generated package.json file to add our default application entry point. Note that we want to place this endpoint file reference at the top of the main object’s attribute list:

{
    "main": "index.js",
    "scripts": {
        "start": "ts-node-dev src/index.ts"
...

Next, our API needs its routes defined, and for those routes to redirect to their handlers. Let’s create a file, src/routes/index.ts, to link user operation routes into our application. We’ll define the route specifics and their handler definitions shortly.

import { Router } from 'express';
import user from './user';

const routes = Router();
// All user operations will be available under the "users" route prefix.
routes.use('/users', user);
// Allow our router to be used outside of this file.
export default routes;

We will now include these routes in the src/index.ts file by importing our routing object and then asking our application to use the imported routes. For reference, you may compare the completed file version with your edited file.

import routes from './routes/index';

// Add our route object to the Express object. 
// This must be before the app.listen call.
app.use('/' + config.prefix, routes);

// app.listen... 

Now our API is ready for us to implement the actual user routes and their handler definitions. We’ll define the user routes in the src/routes/user.ts file and link to the soon-to-be-defined controller, UserController:

import { Router } from 'express';
import UserController from '../controllers/UserController';
import { asyncHandler } from '../middleware/asyncHandler';

const router = Router();

// Note: Each handler is wrapped with our error handling function.
// Get all users.
router.get('/', [], asyncHandler(UserController.listAll));

// Get one user.
router.get('/:id([0-9a-z]{24})', [], asyncHandler(UserController.getOneById));

// Create a new user.
router.post('/', [], asyncHandler(UserController.newUser));

// Edit one user.
router.patch('/:id([0-9a-z]{24})', [], asyncHandler(UserController.editUser));

// Delete one user.
router.delete('/:id([0-9a-z]{24})', [], asyncHandler(UserController.deleteUser));

The handler methods our routes will call rely on helper functions to operate on our user information. Let’s add those helper functions to the tail end of our src/state/users.ts file before we define UserController:

// Place these functions at the end of the file.
// NOTE: Validation errors are handled directly within these functions.

// Generate a copy of the users without their passwords.
const generateSafeCopy = (user : IUser) : IUser => {
    let _user = { ...user };
    delete _user.password;
    return _user;
};

// Recover a user if present.
export const getUser = (id: string): IUser => {
    if (!(id in users)) throw new NotFoundError(`User with ID ${id} not found`);
    return generateSafeCopy(users[id]);
};

// Recover a user based on username if present, using the username as the query.
export const getUserByUsername = (username: string): IUser | undefined => {
    const possibleUsers = Object.values(users).filter((user) => user.username === username);
    // Undefined if no user exists with that username.
    if (possibleUsers.length == 0) return undefined;
    return generateSafeCopy(possibleUsers[0]);
};

export const getAllUsers = (): IUser[] => {
    return Object.values(users).map((elem) => generateSafeCopy(elem));
};


export const createUser = async (username: string, password: string, role: Roles): Promise<IUser> => {
    username = username.trim();
    password = password.trim();

    // Reader: Add checks according to your custom use case.
    if (username.length === 0) throw new ClientError('Invalid username');
    else if (password.length === 0) throw new ClientError('Invalid password');
    // Check for duplicates.
    if (getUserByUsername(username) != undefined) throw new ClientError('Username is taken');

    // Generate a user id.
    const id: string = nextUserId.toString();
    nextUserId++;
    // Create the user.
    users[id] = {
        username,
        password: await bcrypt.hash(password, 12),
        role,
        id
    };
    return generateSafeCopy(users[id]);
};

export const updateUser = (id: string, username: string, role: Roles): IUser => {
    // Check that user exists.
    if (!(id in users)) throw new NotFoundError(`User with ID ${id} not found`);

    // Reader: Add checks according to your custom use case.
    if (username.trim().length === 0) throw new ClientError('Invalid username');
    username = username.trim();
    const userIdWithUsername = getUserByUsername(username)?.id;
    if (userIdWithUsername !== undefined && userIdWithUsername !== id) throw new ClientError('Username is taken');

    // Apply the changes.
    users[id].username = username;
    users[id].role = role;
    return generateSafeCopy(users[id]);
};

export const deleteUser = (id: string) => {
    if (!(id in users)) throw new NotFoundError(`User with ID ${id} not found`);
    delete users[id];
};

export const isPasswordCorrect = async (id: string, password: string): Promise<boolean> => {
    if (!(id in users)) throw new NotFoundError(`User with ID ${id} not found`);
    return await bcrypt.compare(password, users[id].password!);
};

export const changePassword = async (id: string, password: string) => {
    if (!(id in users)) throw new NotFoundError(`User with ID ${id} not found`);
    
    password = password.trim();
    // Reader: Add checks according to your custom use case.
    if (password.length === 0) throw new ClientError('Invalid password');

    // Store encrypted password
    users[id].password = await bcrypt.hash(password, 12);
};

Finally, we can create the src/controllers/UserController.ts file:

import { NextFunction, Request, Response } from 'express';
import { getAllUsers, Roles, getUser, createUser, updateUser, deleteUser } from '../state/users';

class UserController {
    static listAll = async (req: Request, res: Response, next: NextFunction) => {
        // Retrieve all users.
        const users = getAllUsers();
        // Return the user information.
        res.status(200).type('json').send(users);
    };

    static getOneById = async (req: Request, res: Response, next: NextFunction) => {
        // Get the ID from the URL.
        const id: string = req.params.id;

        // Get the user with the requested ID.
        const user = getUser(id);

        // NOTE: We will only get here if we found a user with the requested ID.
        res.status(200).type('json').send(user);
    };

    static newUser = async (req: Request, res: Response, next: NextFunction) => {
        // Get the username and password.
        let { username, password } = req.body;
        // We can only create regular users through this function.
        const user = await createUser(username, password, Roles.USER);

        // NOTE: We will only get here if all new user information 
        // is valid and the user was created.
        // Send an HTTP "Created" response.
        res.status(201).type('json').send(user);
    };

    static editUser = async (req: Request, res: Response, next: NextFunction) => {
        // Get the user ID.
        const id = req.params.id;

        // Get values from the body.
        const { username, role } = req.body;

        if (!Object.values(Roles).includes(role))
            throw new ClientError('Invalid role');

        // Retrieve and update the user record.
        const user = getUser(id);
        const updatedUser = updateUser(id, username || user.username, role || user.role);

        // NOTE: We will only get here if all new user information 
        // is valid and the user was updated.
        // Send an HTTP "No Content" response.
        res.status(204).type('json').send(updatedUser);
    };

    static deleteUser = async (req: Request, res: Response, next: NextFunction) => {
        // Get the ID from the URL.
        const id = req.params.id;

        deleteUser(id);

        // NOTE: We will only get here if we found a user with the requested ID and    
        // deleted it.
        // Send an HTTP "No Content" response.
        res.status(204).type('json').send();
    };
}

export default UserController;

This configuration exposes the following endpoints:

  • /API_PREFIX/users GET: Get all users.
  • /API_PREFIX/users POST: Create a new user.
  • /API_PREFIX/users/{ID} DELETE: Delete a specific user.
  • /API_PREFIX/users/{ID} PATCH: Update a specific user.
  • /API_PREFIX/users/{ID} GET: Get a specific user.

At this point, our API routes and their handlers are implemented.

Step 2: Add and Configure JWT

We now have our basic API implementation, but we still need to implement authentication and authorization to keep it secure. We’ll use JWTs for both purposes. The API will emit a JWT when a user authenticates and verify that each subsequent call is authorized using that authentication token.

For each client call, an authorization header containing a bearer token passes our generated JWT to the API: Authorization: Bearer <TOKEN>.

To support JWT, let’s install some dependencies into our project:

npm install @types/jsonwebtoken --save-dev
npm install jsonwebtoken

One way to sign and validate a payload in JWT is through a shared secret algorithm. For our setup, we chose HS256 as that algorithm, since it is one of the simplest symmetric (shared secret) algorithms available in the JWT specification. We’ll use the Node CLI, along with the crypto package to generate a unique secret:

require('crypto').randomBytes(128).toString('hex');

We can change the secret at any time. However, each change will make all users’ authentication tokens invalid and force them to log out.

Creating the JWT Authentication Controller

For a user to log in and update their passwords, our API’s authentication and authorization functionalities require endpoints that support these actions. To achieve this, we will create src/controllers/AuthController.ts, our JWT authentication controller:

import { NextFunction, Request, Response } from 'express';
import { sign } from 'jsonwebtoken';
import { CustomRequest } from '../middleware/checkJwt';
import config from '../config';
import { ClientError } from '../exceptions/clientError';
import { UnauthorizedError } from '../exceptions/unauthorizedError';
import { getUserByUsername, isPasswordCorrect, changePassword } from '../state/users';

class AuthController {
    static login = async (req: Request, res: Response, next: NextFunction) => {
        // Ensure the username and password are provided.
        // Throw an exception back to the client if those values are missing.
        let { username, password } = req.body;
        if (!(username && password)) throw new ClientError('Username and password are required');

        const user = getUserByUsername(username);

        // Check if the provided password matches our encrypted password.
        if (!user || !(await isPasswordCorrect(user.id, password))) throw new UnauthorizedError("Username and password don't match");

        // Generate and sign a JWT that is valid for one hour.
        const token = sign({ userId: user.id, username: user.username, role: user.role }, config.jwt.secret!, {
            expiresIn: '1h',
            notBefore: '0', // Cannot use before now, can be configured to be deferred.
            algorithm: 'HS256',
            audience: config.jwt.audience,
            issuer: config.jwt.issuer
        });

        // Return the JWT in our response.
        res.type('json').send({ token: token });
    };

    static changePassword = async (req: Request, res: Response, next: NextFunction) => {
        // Retrieve the user ID from the incoming JWT.
        const id = (req as CustomRequest).token.payload.userId;

        // Get the provided parameters from the request body.
        const { oldPassword, newPassword } = req.body;
        if (!(oldPassword && newPassword)) throw new ClientError("Passwords don't match");

        // Check if old password matches our currently stored password, then we proceed.
        // Throw an error back to the client if the old password is mismatched.
        if (!(await isPasswordCorrect(id, oldPassword))) throw new UnauthorizedError("Old password doesn't match");

        // Update the user password.
        // Note: We will not hit this code if the old password compare failed.
        await changePassword(id, newPassword);

        res.status(204).send();
    };
}
export default AuthController;

Our authentication controller is now complete, with separate handlers for login verification and user password changes.

Implementing Authorization Hooks

To ensure that each of our API endpoints is secure, we need to create a common JWT validation and role authentication hook that we can add to each of our handlers. We will implement these hooks into middleware, the first of which will validate incoming JWT tokens in the src/middleware/checkJwt.ts file:

import { Request, Response, NextFunction } from 'express';
import { verify, JwtPayload } from 'jsonwebtoken';
import config from '../config';

// The CustomRequest interface enables us to provide JWTs to our controllers.
export interface CustomRequest extends Request {
    token: JwtPayload;
}

export const checkJwt = (req: Request, res: Response, next: NextFunction) => {
    // Get the JWT from the request header.
    const token = <string>req.headers['authorization'];
    let jwtPayload;

    // Validate the token and retrieve its data.
    try {
        // Verify the payload fields.
        jwtPayload = <any>verify(token?.split(' ')[1], config.jwt.secret!, {
            complete: true,
            audience: config.jwt.audience,
            issuer: config.jwt.issuer,
            algorithms: ['HS256'],
            clockTolerance: 0,
            ignoreExpiration: false,
            ignoreNotBefore: false
        });
        // Add the payload to the request so controllers may access it.
        (req as CustomRequest).token = jwtPayload;
    } catch (error) {
        res.status(401)
            .type('json')
            .send(JSON.stringify({ message: 'Missing or invalid token' }));
        return;
    }

    // Pass programmatic flow to the next middleware/controller.
    next();
};

Our code adds token information to the request, which is then forwarded. Note that the error handler isn’t available at this point in our code’s context because the error handler is not yet included in our Express pipeline.

Next we create a JWT authorization file, src/middleware/checkRole.ts, to validate user roles:

import { Request, Response, NextFunction } from 'express';
import { CustomRequest } from './checkJwt';
import { getUser, Roles } from '../state/users';

export const checkRole = (roles: Array<Roles>) => {
    return async (req: Request, res: Response, next: NextFunction) => {
        // Find the user with the requested ID.
        const user = getUser((req as CustomRequest).token.payload.userId);

        // Ensure we found a user.
        if (!user) {
            res.status(404)
                .type('json')
                .send(JSON.stringify({ message: 'User not found' }));
            return;
        }

        // Ensure the user's role is contained in the authorized roles.
        if (roles.indexOf(user.role) > -1) next();
        else {
            res.status(403)
                .type('json')
                .send(JSON.stringify({ message: 'Not enough permissions' }));
            return;
        }
    };
};

Note that we retrieve the user’s role as stored on the server, instead of the role contained in the JWT. This allows a previously authenticated user to have their permissions changed midstream within their authentication session. Authorization to a route will be correct, regardless of the authorization information that is stored within the JWT.

Now we update our routes files. Let’s create the src/routes/auth.ts file for our authorization middleware:

import { Router } from 'express';
import AuthController from '../controllers/AuthController';
import { checkJwt } from '../middleware/checkJwt';
import { asyncHandler } from '../middleware/asyncHandler';

const router = Router();
// Attach our authentication route.
router.post('/login', asyncHandler(AuthController.login));

// Attach our change password route. Note that checkJwt enforces endpoint authorization.
router.post('/change-password', [checkJwt], asyncHandler(AuthController.changePassword));

export default router;

To add in authorization and required roles for each endpoint, let’s update the contents of our user routes file, src/routes/user.ts:

import { Router } from 'express';
import UserController from '../controllers/UserController';
import { Roles } from '../state/users';
import { asyncHandler } from '../middleware/asyncHandler';
import { checkJwt } from '../middleware/checkJwt';
import { checkRole } from '../middleware/checkRole';

const router = Router();

// Define our routes and their required authorization roles.
// Get all users.
router.get('/', [checkJwt, checkRole([Roles.ADMIN])], asyncHandler(UserController.listAll));

// Get one user.
router.get('/:id([0-9]{1,24})', [checkJwt, checkRole([Roles.USER, Roles.ADMIN])], asyncHandler(UserController.getOneById));

// Create a new user.
router.post('/', asyncHandler(UserController.newUser));

// Edit one user.
router.patch('/:id([0-9]{1,24})', [checkJwt, checkRole([Roles.USER, Roles.ADMIN])], asyncHandler(UserController.editUser));

// Delete one user.
router.delete('/:id([0-9]{1,24})', [checkJwt, checkRole([Roles.ADMIN])], asyncHandler(UserController.deleteUser));

export default router;

Each endpoint validates the incoming JWT with the checkJwt function and then authorizes the user roles with the checkRole middleware.

To finish integrating the authentication routes, we need to attach our authentication and user routes to our API’s route list in the src/routes/index.ts file, replacing its contents:

import { Router } from 'express';
import user from './user';

const routes = Router();
// All auth operations will be available under the "auth" route prefix.
routes.use('/auth', auth);
// All user operations will be available under the "users" route prefix.
routes.use('/users', user);
// Allow our router to be used outside of this file.
export default routes;

This configuration now exposes the additional API endpoints:

  • /API_PREFIX/auth/login POST: Log in a user.
  • /API_PREFIX/auth/change-password POST: Change a user’s password.

With our authentication and authorization middleware in place, and the JWT payload available in each request, our next step is to make our endpoint handlers more robust. We’ll add code to ensure users have access only to the desired functionalities.

Integrate JWT Authorization into Endpoints

To add extra validations to our endpoints’ implementation in order to define the data each user can access and/or modify, we’ll update the src/controllers/UserController.ts file:

import { NextFunction, Request, Response } from 'express';
import { getAllUsers, Roles, getUser, createUser, updateUser, deleteUser } from '../state/users';
import { ForbiddenError } from '../exceptions/forbiddenError';
import { ClientError } from '../exceptions/clientError';
import { CustomRequest } from '../middleware/checkJwt';

class UserController {
    static listAll = async (req: Request, res: Response, next: NextFunction) => {
        // Retrieve all users.
        const users = getAllUsers();
        // Return the user information.
        res.status(200).type('json').send(users);
    };

    static getOneById = async (req: Request, res: Response, next: NextFunction) => {
        // Get the ID from the URL.
        const id: string = req.params.id;

        // New code: Restrict USER requestors to retrieve their own record.
        // Allow ADMIN requestors to retrieve any record.
        if ((req as CustomRequest).token.payload.role === Roles.USER && req.params.id !== (req as CustomRequest).token.payload.userId) {
            throw new ForbiddenError('Not enough permissions');
        }

        // Get the user with the requested ID.
        const user = getUser(id);

        // NOTE: We will only get here if we found a user with the requested ID.
        res.status(200).type('json').send(user);
    };

    static newUser = async (req: Request, res: Response, next: NextFunction) => {
        // NOTE: No change to this function.
        // Get the user name and password.
        let { username, password } = req.body;
        // We can only create regular users through this function.
        const user = await createUser(username, password, Roles.USER);

        // NOTE: We will only get here if all new user information 
        // is valid and the user was created.
        // Send an HTTP "Created" response.
        res.status(201).type('json').send(user);
    };

    static editUser = async (req: Request, res: Response, next: NextFunction) => {
        // Get the user ID.
        const id = req.params.id;

        // New code: Restrict USER requestors to edit their own record.
        // Allow ADMIN requestors to edit any record.
        if ((req as CustomRequest).token.payload.role === Roles.USER && req.params.id !== (req as CustomRequest).token.payload.userId) {
            throw new ForbiddenError('Not enough permissions');
        }

        // Get values from the body.
        const { username, role } = req.body;

        // New code: Do not allow USERs to change themselves to an ADMIN.
        // Verify you cannot make yourself an ADMIN if you are a USER.
        if ((req as CustomRequest).token.payload.role === Roles.USER && role === Roles.ADMIN) {
            throw new ForbiddenError('Not enough permissions');
        }
        // Verify the role is correct.
        else if (!Object.values(Roles).includes(role)) 
             throw new ClientError('Invalid role');

        // Retrieve and update the user record.
        const user = getUser(id);
        const updatedUser = updateUser(id, username || user.username, role || user.role);

        // NOTE: We will only get here if all new user information 
        // is valid and the user was updated.
        // Send an HTTP "No Content" response.
        res.status(204).type('json').send(updatedUser);
    };

    static deleteUser = async (req: Request, res: Response, next: NextFunction) => {
        // NOTE: No change to this function.
        // Get the ID from the URL.
        const id = req.params.id;

        deleteUser(id);

        // NOTE: We will only get here if we found a user with the requested ID and    
        // deleted it.
        // Send an HTTP "No Content" response.
        res.status(204).type('json').send();
    };
}

export default UserController;

With a complete and secure API, we can begin testing our code.

Step 3: Test JWT and Node.js

To test our API, we must first start our project:

npm run start

Next, we’ll install Postman, and then create a request to authenticate a test user:

  1. Create a new POST request for user authentication.
  2. Name this request “JWT Node.js Authentication.”
  3. Set the request’s address to localhost:3000/api/auth/login.
  4. Set the body type to raw and JSON.
  5. Update the body to contain this JSON value:
  6. {
        "username": "testadmin1",
        "password": "testadmin1_password"
    }
    
  7. Run the request in Postman.
  8. Save the return JWT information for our next call.

Now that we have a JWT for our test user, we’ll create another request to test one of our endpoints and get the available USER records:

  1. Create a new GET request for user authentication.
  2. Name this request “JWT Node.js Get Users.”
  3. Set the request’s address to localhost:3000/api/users.
  4. On the request’s authorization tab, set the type to Bearer Token.
  5. Copy the return JWT from our previous request into the “Token” field on this tab.
  6. Run the request in Postman.
  7. View the user list returned by our API.

These examples are just a few of many possible tests. To fully explore the API calls and test our authorization logic, follow the demonstrated pattern to create additional tests.

Better Node.js and JWT Security

When we combine JWT into a Node.js API, we gain leverage with industry-standard libraries and implementations to maximize our results and minimize developer effort. JWT is both feature-rich and developer-friendly, and it is easy to implement in our app with a minimal learning curve for developers.

Nevertheless, developers must still exercise caution when adding JWT security to their projects to avoid common pitfalls. By following our guidance, developers should feel empowered to better apply JWT implementations within Node.js. JWT’s trusted security in combination with the versatility of Node.js provides developers great flexibility to create solutions.


The editorial team of the Toptal Engineering Blog extends its gratitude to Abhijeet Ahuja and Mohamed Khaled for reviewing the code samples and other technical content presented in this article.

Understanding the basics

  • What is JWT and how does it work?

    JSON Web Token (JWT) is an open standard for safely exchanging information in space-constrained environments. JWT contains all required information for message validation. The provided signature is used to verify that message content is true and unadulterated.

  • How is JWT encoded?

    JWT uses base64 to encode its three main components: the header, payload, and signature. JWT provides a compact and URL-safe token.

  • What is the purpose of JWT?

    JWT defines an industry-agnostic and open standard for transmitting data compactly.

  • What is the structure of JWT?

    While a JWT could vary in structure, its main components are: header, containing information used for validation; payload, holding custom data from the issuing server; and signature, confirming that a token’s contents haven’t been tampered with.

  • What is JWT security?

    JWT security is the implementation of tokens that follow the JWT standard. It defines algorithms, encryption, and fields to allow safe token authentication and authorization.

  • How does JWT integrate with Node.js?

    JWT provides a security layer that can integrate seamlessly with new or existing Node.js APIs to authenticate and authorize user requests. JWT can also be the basis of stateless sessions.

  • What is Node.js used for?

    Node.js is a JavaScript runtime environment designed to build scalable network applications. It is commonly used to create servers and APIs.

  • When should Node.js be used?

    Given the scalability of Node.js, it can be used in both hobby and enterprise solutions. The versatility of Node.js allows for its use in any industry.

Freelancer? Find your next job.
Remote Freelance Jobs
Gonzalo Hirsch's profile image
Gonzalo Hirsch

Located in London, United Kingdom

Member since February 1, 2021

About the author

testing custom bottom

Toptalauthors are vetted experts in their fields and write on topics in which they have demonstrated experience. All of our content is peer reviewed and validated by Toptal experts in the same field.

Previously At

Croud

World-class articles, delivered weekly.

Subscription implies consent to our privacy policy

World-class articles, delivered weekly.

Subscription implies consent to our privacy policy

Join the Toptal® community.