How to Secure Your Node.js API with JWT Authentication

Photo by analuisa gamboa / Unsplash
Photo by analuisa gamboa / Unsplash

When building a backend API, it is essential to protect access to restricted resources through authentication and authorization.

One of the most popular and effective authentication methods is JSON Web Tokens (JWT). It provides a flexible and stateless way to verify users' identities and secure API endpoints, called Token-Based Authentication.

In this article, we will build a JWT authentication in a Node.js backend application.

What we will build

The API must expose routes where some are accessible without authentication while others require one. The table below enumerates them:

API route Access status Description
[POST] /auth/signup Unprotected Register a new user
[POST] /auth/login Unprotected Authenticate a user
[GET] /users/me Protected Retrieve the current authenticated user
[GET] /users Protected Retrieve all the users

Prerequisites

To follow this tutorial, make sure you have the following tools installed on your computer.

If you have Docker, you can start a Docker container of MongoDB from the Docker image. Run the command below to start the Docker container from the MongoDB image:


docker run -d --rm -e MONGO_INITDB_ROOT_USERNAME=user -e MONGO_INITDB_ROOT_PASSWORD=secret -p 27018:27017 --name mongodb mongo:8.0

The MongoDB URL is mongodb://user:secret@localhost:27018/admin.

Set up the project

We will use a boilerplate for the Node.js project we built in the tutorial below. The main Git branch contains a simple Node.js application.

Build a Node.js project with TypeScript, ESLint and Prettier
In this tutorial, we will create a Node.js starter project with TypeScript, ESLint, and Prettier. Define some ESLInt rules to check on our code and automatically fix the errors found.

Read my complete tutorial on how to connect a Node.js application to MongoDB.

Let's clone the project and run it locally by running the following commands:


git clone https://github.com/tericcabrel/node-ts-starter.git -b express-mongo node-api-jwt-auth

cd node-api-jwt-auth

cp .env.example .env

nano .env
# Set the MONGODB_URL to "mongodb://user:secret@localhost:27018/admin"

yarn install

yarn start


Open your browser and navigate to http://localhost:4500

Run the Node.js application connected to MongoDB locally.
Run the Node.js application connected to MongoDB locally.

Create the user entity

To authenticate a user, we must store their information in the database, and the first step is to define the database schema.

We will define the user schema using Mongoose to create a Mongo collection at the application start-up.

Create the file "src/models/user.model.ts" and add the code below:


import mongoose, { Schema, Model, Document } from 'mongoose';

type UserDocument = Document & {
  fullName: string;
  email: string;
  password: string;
};

type UserInput = {
  fullName: UserDocument['fullName'];
  email: UserDocument['email'];
  password: UserDocument['password'];
};

const usersSchema = new Schema(
  {
    fullName: {
      type: Schema.Types.String,
      required: true,
    },
    email: {
      type: Schema.Types.String,
      required: true,
      unique: true,
    },
    password: {
      type: Schema.Types.String,
      required: true,
    },
  },
  {
    collection: 'users',
    timestamps: true,
  },
);

const User: Model<UserDocument> = mongoose.model<UserDocument>('User', usersSchema);

export { User, UserInput, UserDocument };

The user will contain a full name, an email address, and a password. Additionally, Mongoose will add the following fields: _id, createdAt and updatedAt.

The field "_id" is an autogenerated Mongo object ID representing the user.

The fields "createdAt" and "updatedAt" store the values for when the user is created and updated. In the above code, we indicate their creation with the option timestamps: true.

Create the JWT helpers

To authenticate with JWT, we must perform the following actions:

  • Create a JWT token after a successful login
  • Decode a JWT token to extract the user identifier.

When creating a JWT token, we must define a payload containing information about the user for whom the token is made.

To create and decode a JWT token, we will use the Node.js package jsonwebtoken; let's install it:


yarn add jsonwebtoken

Create a file "helpers/jwt.ts" and add the code below:


import * as jwt from 'jsonwebtoken';

export type TokenPayload = {
  id: string;
};

export const generateJwtToken = (payload: TokenPayload, jwtSecret: string, jwtExpire: string): string => {
  return jwt.sign(payload, jwtSecret, { expiresIn: jwtExpire });
};

export const decodeJwtToken = (token: string, jwtSecret: string): Promise<TokenPayload> => {
  return new Promise((resolve, reject): void => {
    jwt.verify(token, jwtSecret, (err: jwt.VerifyErrors | null, decoded) => {
      if (err) {
        if (err instanceof jwt.TokenExpiredError) {
          reject(new Error('Token expired'));
        }

        reject(new Error('Token is not valid'));
      }

      resolve(decoded as TokenPayload);
    });
  });
};

Generating a JWT token requires a JWT secret and a JWT expiration date. These values must be injected as environment variables.

Open the file ".env" and add the following code:


JWT_SECRET=myStr0ngS3cre7
JWT_EXPIRATION_DATE=3600

We set the expiration date to 1 hour (3600 seconds), meaning the user must authenticate again an hour after the JWT token creation.

You can set the value you want for the JWT_SECRET variable.

Create the JWT authentication middleware

For every request, we want to retrieve the JWT token in the header "Authorization" and validate it:

  • If the token is invalid, reject the request or continue otherwise.
  • If the token is valid, extract the username, find the related user in the database, and set it in the authentication context so you can access it in any application layer.

With Express, you can create a function that will be executed before processing the request; we call it middleware. A use case of middleware is getting the user's IP address to serve content based on the country.

Create a file "configs/auth.middleware.ts" and add the code below:


import { NextFunction, Request, Response } from 'express';
import { decodeJwtToken, TokenPayload } from '../helpers/jwt';

export type CustomRequest = {
  user: { id: string };
} & Request;

const JWT_SECRET = process.env.JWT_SECRET || '';
const ALLOWED_ROUTES: string[] = ['/', '/health', '/auth/signup', '/auth/login'];

const isAuthorizedRoute = (currentRoute: string): boolean => {
  return ALLOWED_ROUTES.some((route) => currentRoute === route);
};

export const authMiddleware = async (req: CustomRequest | any, res: Response, next: NextFunction) => {
  let routeName = '';

  if (req.originalUrl) {
    routeName = req.originalUrl;
  } else {
    return res.status(401).json({ message: 'Unauthorized access' });
  }

  if (isAuthorizedRoute(routeName)) {
    return next();
  }

  const token = req.headers['authorization'];

  if (token) {
    try {
      const decoded = (await decodeJwtToken(token.replace('Bearer ', ''), JWT_SECRET)) as TokenPayload | undefined;

      if (!decoded?.id) {
        return res.status(401).json({ message: 'Unauthorized access' });
      }

      // Inject authenticated user in the Request object in order get access in the controllers
      req.user = decoded;

      return next();
    } catch (err) {
      console.error(err);
    }
  }

  return res.status(401).json({ message: 'Unauthorized access' });
};

The above code does the following:

  • Retrieve the request URL called from Node.js API.
  • Check if the request's URL can be called without authentication. We declared an array containing the authorized routes such as "/auth/signup" and "/auth/login".
  • If the URL requires authentication, decode the JWT token from the request's header.
  • If the token is valid, inject a property "user" in the Express request object containing the JWT decode payload and forward the request to the next middleware.
  • If the token is invalid, return an HTTP 401 error.

Create route handlers for user registration

The API exposes the route "auth/signup" to register new users, which we call an internal function to store user information in the database.

Let's create a file "src/controllers/auth.controller.ts" and add the code below:


import { Request, Response } from 'express';
import * as bcrypt from 'bcryptjs';
import { User, UserInput } from '../models/user.model';

export const registerUser = async (req: Request, res: Response) => {
  const { email, fullName, password } = req.body;

  if (!email || !fullName || !password) {
    return res.status(422).json({ message: 'The fields email, fullName and password are required' });
  }

  const userInput: UserInput = {
    fullName,
    email,
    password: bcrypt.hashSync(password, 10),
  };

  const userCreated = await User.create(userInput);

  return res.status(201).json({ data: userCreated });
};

The above code extracts the user input from the request body and validates the expected fields.

Once the request body is validated, the user password is hashed, and finally, the user information is persisted in the MongoDB collection.

We use the Node.js library Bcrypt to hash the user password; let's install it:


yarn add bcryptjs
yarn add @types/bcryptjs

Create route handlers for user authentication

This function will be executed when the API route "auth/login" is called.

Add the following code to the file "src/controllers/auth.controller.ts"


import { Request, Response } from 'express';
import * as bcrypt from 'bcryptjs';
import { User, UserInput } from '../models/user.model';
import { generateJwtToken, TokenPayload } from '../helpers/jwt';

const JWT_SECRET = process.env.JWT_SECRET || '';
const JWT_EXPIRE = parseInt(process.env.JWT_EXPIRE || '3600', 10);

// registerUser() function here...

export const authenticateUser = async (req: Request, res: Response): Promise<Response> => {
  const { email, password } = req.body;

  const user = await User.findOne({ email });

  if (!user) {
    return res.status(400).json({ message: 'Email or password is incorrect' });
  }

  const isMatch: boolean = bcrypt.compareSync(password, user.password);

  if (!isMatch) {
    return res.status(400).json({ message: 'Email or password is incorrect' });
  }

  const tokenInfo: TokenPayload = {
    id: user.id,
  };
  const token = generateJwtToken(tokenInfo, JWT_SECRET, JWT_EXPIRE);

  return res.json({ token, expiresIn: JWT_EXPIRE });
};

Configure API routes for authentication

We must link the routes "auth/signup" and "auth/login" to their respective function handlers. Also, register the authentication middleware.


import express from 'express';
import { connectToDatabase } from './db-connection';
import { authenticateUser, registerUser } from './controllers/auth.controller';
import { authMiddleware } from './config/auth.middleware';

const HOST = process.env.HOST || 'http://localhost';
const PORT = parseInt(process.env.PORT || '4500');

const app = express();

app.use(express.urlencoded({ extended: true }));
app.use(express.json());

app.use(authMiddleware);

app.post('/auth/signup', registerUser);
app.post('/auth/login', authenticateUser);

app.get('/', (req, res) => {
  return res.json({ message: 'Hello World!' });
});

app.listen(PORT, async () => {
  await connectToDatabase();

  console.log(`Application started on URL ${HOST}:${PORT} 🎉`);
});

Test the implementation

Run the application with yarn start, open an HTTP client, then

  • Send a POST request to "/auth/signup" with user fields in the request body.
  • Send a POST request to "/auth/login" to authenticate the user.
0:00
/0:20

Call the API routes to register a new user and authenticate a user.

Create restricted endpoints to retrieve users

The endpoints "/users/me" and "/users" return the authenticated user from the JWT token provided and a list of all the users.

Create a file "/src/controllers/user.controller.ts" and add the code below:


import { Response } from 'express';
import { CustomRequest } from '../config/auth.middleware';
import { User } from '../models/user.model';

export const getAllUsers = async (req: CustomRequest, res: Response) => {
  const users = await User.find().sort('-createdAt').exec();

  return res.status(200).json({ data: users });
};

export const getAuthenticatedUser = async (req: CustomRequest, res: Response) => {
  const { id } = req.user;

  const user = await User.findOne({ _id: id });

  if (!user) {
    return res.status(404).json({ message: `User with id "${id}" not found.` });
  }

  return res.status(200).json({ data: user });
};

Let's link the routes "/users" and "/users/me" to their respective function handlers by adding the code below in the file "src/index.ts"


import { getAllUsers, getAuthenticatedUser } from './controllers/user.controller';


app.get('/users', getAllUsers);
app.get('/users/me', getAuthenticatedUser);

Test the implementation

Re-run the application and execute the following scenario:

  1. Send a GET request to /users/me and /users, you will get a 401 error
  2. Authenticate with POST request at /auth/login and obtain the JWT token.
  3. Put the JWT token in the authorization header of the request /users/me and /users; you will get an HTTP response code 200 with the data.
0:00
/0:30

Call the protected API routes using the JWT generated from the user authentication.

Caveats on JWT authentication

A JWT authentication protects your API, which prevents unauthenticated users from accessing restricted API endpoints.

You must know of some drawbacks of using JWT as your authentication mechanism.

  • Token size: The more information you add to the token payload, the longer the token generated will be. This can impact the Network bandwidth.
  • Token revocation: When the JWT token is generated, it is valid until its expiration date, meaning even if the user logs out of the application, calling the API with the JWT token will still return the data.
  • Stale data: If a JWT token contains the user role and this user is downgraded until this token expires, the user can still access data with a privilege higher than the current one.
  • Degraded User Experience: It is recommended to set the expiration date short to prevent token revocation issues, but this implies the user to authenticate many times but also unexpected errors due to API unauthorized access.

Wrap up

In this post, we saw how to implement the JSON Web Token authentication in a Node.js application. We can summarize the process in the following steps:

  • Create helper functions to create and decode a JWT token
  • Create an authentication middleware to extract and validate the token from the request header.
  • Whitelist some API routes and protect those requiring a token.
  • Perform the authentication, generate the JWT, and set an expiration time.
  • Use the JWT generated to access protected routes.

With this implementation, you have the basis to protect your Node.js API. You can go further by implementing a Role-Based Access Control (RBAC) to restrict a resource based on the user's role and permission.

You can find the code source on the GitHub repository.

Follow me on Twitter or subscribe to my newsletter to avoid missing the upcoming posts and the tips and tricks I occasionally share.