Improving App Security with JWT and Node.js

Safeguarding sensitive information is crucial when building APIs that handle client requests. A robust API should effectively identify and block unauthorized access attempts. Utilizing a JSON Web Token (JWT) mechanism enables the validation and potential encryption of client requests, enhancing security.

This tutorial provides a step-by-step guide on integrating JWT security into a Node.js API. While various approaches exist for implementing API security, JWT stands out as a popular and developer-friendly solution widely adopted in Node.js API projects.

Understanding JWT

JWT, an open standard, facilitates secure information exchange within constrained environments using a JSON format. Its simplicity and compactness make it suitable for a wide array of applications, effectively combining various security standards.

Encoded data within JWTs can be either encrypted and hidden or signed for readability. Encrypted tokens contain all necessary hashing and algorithmic details for decryption. Signed tokens, on the other hand, allow recipients to verify their integrity and detect any tampering. JSON Web Signature (JWS), the prevalent approach for signed tokens, enables this tamper detection.

A JWT comprises three primary components, each consisting of name-value pairs:

The JWT header, defined using the JOSE standard, conveys the token type and cryptographic details. The mandatory 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 employ both symmetric and asymmetric algorithms for tamper detection. (Specific algorithms may necessitate additional header name-value pairs, but a full exploration of these headers falls outside this article’s scope.)

Payload

The JWT payload carries the encoded (and potentially encrypted) data exchanged between parties. It consists of a collection of claims, each represented as a name-value pair. These claims constitute the meaningful information being transmitted, excluding headers and metadata. The payload remains protected within a secure communication channel, sealed using the token’s signature.

Claim names can either be from the JWT’s reserved set or custom-defined. When defining custom names, it’s best to avoid using reserved names to prevent confusion.

Certain reserved names are always required in the payload:

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

A signature (JWS) is recommended for verifying a JWT’s authenticity. It is a URL-safe, base64-encoded string that validates the token’s integrity.

The signature generation process depends on the algorithm specified in the header. Both the header and payload are inputs to this algorithm:

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

Any party, including the recipient, can independently calculate the signature using this method and compare it against the JWT’s signature to verify authenticity.

While tokens containing sensitive data should be encrypted (i.e., using JWE), tokens with non-sensitive data can utilize JWS for encoding and publicly exposing the payload claims. JWS enables recipients to detect modifications or corruption by third parties.

Common Applications of JWT

Let’s explore common use cases for JWT, focusing on the most prevalent scenarios.

API Authentication

JWTs are often used for API authentication, particularly in e-commerce. Upon successful authentication, the API issues a JWT to the client, who then includes it in subsequent API calls. The API layer validates the token, granting access to appropriate routes, services, and resources based on the authenticated user’s permissions.

Federated Identity

JWT plays a significant role in federated identity systems, where user identities are linked across multiple platforms (e.g., using Gmail for login on a third-party site). A central authentication system verifies client identities and issues JWTs for accessing connected APIs and services.

Unlike simple API tokens, federated systems typically employ two types of tokens: access tokens and refresh tokens. Access tokens are short-lived and grant temporary access to protected resources. Refresh tokens, on the other hand, have longer lifespans and allow clients to obtain new access tokens from authorization servers without re-entering credentials.

Stateless Sessions

Stateless session authentication resembles API authentication but includes more information within the JWT transmitted with each request. Data primarily resides client-side; for example, an e-commerce application might store user cart information in a JWT.

This approach eliminates the need for servers to maintain per-user session state. Stateless sessions require storing more information client-side, necessitating that the JWT carries details about user interactions (e.g., shopping cart contents, redirect URLs). Consequently, JWTs in stateless sessions contain more information compared to stateful sessions.

Best Practices for JWT Security

Adhering to best practices is essential to mitigate common security risks associated with JWT:

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 practices in mind, let’s delve into creating a JWT and implementing it in a Node.js example. We’ll build a project demonstrating authentication and authorization using JWT, following three key steps.

We’ll utilize Express for its ease of use in building backend applications, simplifying JWT integration. Postman will be our testing tool, facilitating collaboration and standardized testing.

The final, deployable project repository serves as a reference throughout this tutorial.

Step 1: Building the Node.js API

Let’s begin by creating the project folder and initializing a Node.js project:

1
2
3
mkdir jwt-nodejs-security
cd jwt-nodejs-security
npm init -y

Next, we’ll install project dependencies and generate a basic tsconfig file (unchanged during this tutorial) for TypeScript:

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

With the foundation set, let’s define our API project.

API Environment Configuration

Our project will utilize system environment variables within the code. Let’s create a configuration file, src/config/index.ts, to retrieve and make these variables accessible:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
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 setting environment variables either directly in the operating system or through an .env file. We’ll opt for the latter, defining the following values:

  • JWT_SECRET
  • JWT_AUDIENCE
  • JWT_ISSUER
  • PORT
  • API_PREFIX

Your .env file should resemble the repository example. With the basic API configuration in place, let’s move on to implementing data storage.

In-memory Storage Setup

To avoid database complexities, we’ll store data locally within the server state. Let’s create a TypeScript file, src/state/users.ts, to hold our storage and CRUD operations for API user data:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
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 diving into specific routing and handlers, let’s focus on error handling to maintain consistency with JWT best practices.

Custom Error Handling

Express requires additional handling for asynchronous operations, as it doesn’t natively catch promise rejections within asynchronous handlers. To address this, we’ll create an error-handling wrapper function.

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

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
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 channels promise errors to an error handler. Before implementing the handler itself, we’ll define custom exceptions in src/exceptions/customError.ts:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
// 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 can create our error handler in src/middleware/errorHandler.ts:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
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));
    }
}

While we’ve established general error handling, we also need the capability to throw descriptive errors from within our API handlers. Let’s define these utility functions, each in a separate file:

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

1
2
3
4
5
6
7
import { CustomError } from './customError';

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

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

1
2
3
4
5
6
7
import { CustomError } from './customError';

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

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

1
2
3
4
5
6
7
import { CustomError } from './customError';

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

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

1
2
3
4
5
6
7
import { CustomError } from './customError';

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

With the project basics and error handling in place, let’s define our API endpoints and their corresponding handler functions.

Defining API Endpoints

We’ll create a new file, src/index.ts, to serve as our API’s entry point:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
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 modify the npm-generated package.json to include our default application entry point. This entry point file reference should be placed at the beginning of the main object’s attribute list:

1
2
3
4
5
{
    "main": "index.js",
    "scripts": {
        "start": "ts-node-dev src/index.ts"
...

Next, we’ll define our API routes and their respective handlers. Let’s create src/routes/index.ts to connect user operation routes within our application. We’ll define the specific routes and their handler definitions shortly.

1
2
3
4
5
6
7
8
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’ll now incorporate these routes into src/index.ts by importing our routing object and instructing our application to use them. For reference, you can compare the completed file version with your modified file.

1
2
3
4
5
6
7
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, let’s implement the actual user routes and their handler definitions in src/routes/user.ts. We’ll link these routes to the UserController, which we’ll define soon:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
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));

Our route handlers rely on helper functions to manipulate user information. Let’s add these functions to the end of src/state/users.ts before defining UserController:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
// 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, let’s create the src/controllers/UserController.ts file:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
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: Retrieve 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: Retrieve a specific user.

With our API routes and handlers in place, let’s move on to securing our API.

Step 2: Incorporating and Configuring JWT

Our basic API is ready, but it lacks authentication and authorization. We’ll use JWTs to address both. Upon user authentication, the API will issue a JWT, subsequently verifying each request using this token.

Client calls will include an authorization header containing a bearer token that carries our generated JWT: Authorization: Bearer <TOKEN>.

Let’s install the necessary JWT dependencies:

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

JWT payload signing and validation can be achieved using shared secret algorithms. We’ll utilize HS256 due to its simplicity among symmetric algorithms supported by the JWT specification. We’ll generate a unique secret using the Node CLI and the crypto package:

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

While the secret can be changed, doing so invalidates existing authentication tokens, forcing users to re-authenticate.

Creating the JWT Authentication Controller

Our API requires endpoints for user login and password updates to implement authentication and authorization. Let’s create src/controllers/AuthController.ts to handle JWT authentication:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
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, handling login verification and password changes.

Implementing Authorization Hooks

To secure all our API endpoints, we’ll create a common JWT validation and role authentication hook applicable to each handler. We’ll implement these hooks as middleware. First, we’ll validate incoming JWT tokens in src/middleware/checkJwt.ts:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
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 passed on. Note that the error handler is not yet accessible here, as it hasn’t been integrated into our Express pipeline.

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

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
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;
        }
    };
};

Instead of relying on the role within the JWT, we retrieve the user’s role from the server. This approach ensures accurate authorization even if user permissions change mid-session.

Now, let’s update our routes files. We’ll create src/routes/auth.ts for our authorization middleware:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
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 enforce authorization and role checks for each endpoint, we’ll modify src/routes/user.ts:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
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 now validates incoming JWTs using checkJwt and authorizes user roles with checkRole.

Finally, let’s integrate the authentication routes by updating src/routes/index.ts:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
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 adds the following API endpoints:

  • /API_PREFIX/auth/login POST: User login.
  • /API_PREFIX/auth/change-password POST: Password change.

With authentication and authorization middleware in place, along with JWT payload access in each request, let’s enhance our endpoint handlers.

Integrating JWT Authorization into Endpoints

We’ll update src/controllers/UserController.ts to include additional validations, ensuring users can only access and modify data according to their permissions:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
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 secure API in place, we can proceed to testing.

Step 3: Testing JWT and Node.js

Before testing, let’s start our project:

1
npm run start

Next, we’ll install Postman and 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, let’s test one of our endpoints by retrieving 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 address to localhost:3000/api/users.
  4. In the authorization tab, select Bearer Token.
  5. Paste the JWT obtained from the previous request into the “Token” field.
  6. Execute the request in Postman.
  7. Observe the user list returned by our API.

These are just a few examples. You can create additional tests to explore various API calls and thoroughly test our authorization logic.

Enhanced Node.js and JWT Security

Integrating JWT into a Node.js API allows leveraging industry-standard libraries and implementations, maximizing efficiency and minimizing development effort. JWT’s feature-rich yet developer-friendly nature, combined with Node.js’s versatility, offers a powerful solution for building secure applications.

However, caution must be exercised when implementing JWT security to avoid common pitfalls. By following these guidelines, developers can confidently implement JWT within their Node.js projects. The combination of JWT’s trusted security and Node.js’s flexibility empowers developers to create robust and secure solutions.


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

Licensed under CC BY-NC-SA 4.0