Simple Authentication Using JWT (Node.js / Express)

Whenever you begin learning Backend Development, the first thing you're going to learn is how to authenticate and authorize requests that are coming in.

In this article we will implement a standard, stateless, authentication system, using JWT (JSON Web Tokens) and Express (Node.js).

Node.js Authentication & Authorization Tutorial using JWT and Express

15 minutes read

Published:30/12/2022

Author:Valentin Constanda, Founder

Category:Back-end Development

Technology:Node.js

Starting things off, this tutorial is intended to teach Entry & Junior Level Backend Developers the basics about Authorization and Authentication on the Backend.

The code we are going to show is acceptable for production environments with standard security requirements.

In future articles we will show more advanced techniques such as authenticating users using AWS Cognito.

Note for non-technical readers: The first part of the article will explain what a JSON Web Token is, in Layman's terms.

Feel free to continue reading, we will let you know when we're going technical.

If you're here looking only for the code, you can find it on GitHub: https://github.com/Pro-Application-Tech/express-jwt-authentication-authorization

What is a JWT (JSON Web Token)?

In short, the JWT is a very long, single-word, text that contains some encoded data.

A JWT looks like this:

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IlNvIHlvdSBkZWNpZGVkIHRvIGRlY29kZSB0aGUgSldULCBkaWRuJ3QgeW91PyBXZSB0aGluayB3ZSBtaWdodCBsaWtlIHlvdSwgY29udGFjdCB1cyBpZiB5b3UncmUgbG9va2luZyBmb3IgYSBqb2IgOikiLCJpYXQiOjE1MTYyMzkwMjJ9.4dpYL5TgA12AXQOdGHGg7YH5iuJ0H0a4UNbifWDOW1k

The encoded data represents information that the system (the Backend of an application) creating the JWT has used when signing it:

  • the type of algorithm used to encode the encoded JWT

  • the expiration time of the JWT

  • arbitrary information, about anything. When used for Authentication, this is usually represented by information about a User.

Note that a JWT can be used for anything that requires some level of Authorization, such as:

  • giving temporary access, over the internet, to a digital product that you are selling, such as a video tutorial

  • verifying the authenticity of a person's identity when they are resetting their account's password

  • in a Single-Sign On system, passing a temporary JWT in the URL so that the target website authenticates the User

For an in-depth description of what a JSON Web Token is, you can refer to https://jwt.io/introduction

As promised, this is the end of the non-technical part of the article. Moving forward, we will go into detail on how to implement JWT Authentication in a Node.js Application.

Before we begin

If you feel that the pace of this tutorial is slightly too quick, let us know by sending me and email at: [email protected] and I will respond to your shortly, and make the necessary adjustments.

We value your feedback as we'd love to continue writing good and useful content.

Initial setup

Prerequisites:

We will start off this project with:

At the end of the article, you will have an Express application working locally, which you will be able to test using Postman.

In one of our next articles, we will also show you how to attach a database to this application and launch it on AWS EC2.

Let's begin

First we will create a working folder. Open the terminal and type:

cd ~ && mkdir ExpressJwtAuthentication && cd ExpressJwtAuthentication

Next, we will create a new Express App:

npx express-generator express-jwt-authentication-authorization

The output of this command will be:

Valentin-MacBook-Pro:ExpressJwtAuthentication valentin$ npx express-generator express-jwt-authentication-authorization

  warning: the default view engine will not be jade in future releases
  warning: use `--view=jade' or `--help' for additional options


   create : express-jwt-authentication-authorization/
   create : express-jwt-authentication-authorization/public/
   create : express-jwt-authentication-authorization/public/javascripts/
   create : express-jwt-authentication-authorization/public/images/
   create : express-jwt-authentication-authorization/public/stylesheets/
   create : express-jwt-authentication-authorization/public/stylesheets/style.css
   create : express-jwt-authentication-authorization/routes/
   create : express-jwt-authentication-authorization/routes/index.js
   create : express-jwt-authentication-authorization/routes/users.js
   create : express-jwt-authentication-authorization/views/
   create : express-jwt-authentication-authorization/views/error.jade
   create : express-jwt-authentication-authorization/views/index.jade
   create : express-jwt-authentication-authorization/views/layout.jade
   create : express-jwt-authentication-authorization/app.js
   create : express-jwt-authentication-authorization/package.json
   create : express-jwt-authentication-authorization/bin/
   create : express-jwt-authentication-authorization/bin/www

   change directory:
     $ cd express-jwt-authentication-authorization

   install dependencies:
     $ npm install

   run the app:
     $ DEBUG=express-jwt-authentication-authorization:* npm start

Let's open the folder and install dependencies for the project:

cd express-jwt-authentication-authorization && npm install

You may now open the application using your favourite text editor or IDE.

We now a starter Express App and we're moving on to installing the necessary packages for Authentication and Authorization

npm install --save dotenv jsonwebtoken nodemon mongoose bcrypt
dotenv

This package will manage the environment variables of your application, using the .env file.

The .env file must always be treated as sensitive, therefore we will need to make sure that it is never exposed via git.

jsonwebtoken

This package will give us access to the functions necessary to generate and verify a JWT.

It will require us to have a secret string, using which we will sign the JWT. The secret can be used to decode any of tokens that have been issued, therefore it must be in the .env file, and you must keep your .env file safe.

Whenever you feel like there has been a breach of security, or someone has your secret, you must change it.

The impact will be that all previously issued tokens will be invalidated, but your user's accounts will be safe.

nodemon

This package will allow us to develop our application faster by automatically refreshing it once we save changes.

mongoose

Mongoose is an ODM (Object Data Modeling) library that allows a Node.js Application to interact with a MongoDB Database.

bcrypt

bcrypt is a password-hashing function which we will leverage to hash the passwords of our users before saving them in MongoDB.

Once the five packages have been successfully installed, we may create the following config files: (the path is represented relative to the root of the project)

./.gitignore
# .gitignore

# Logs
npm-debug.log*

# Dependency directories
node_modules/

# dotenv environment variable files
.env

# Mac
.DS_Store

# IDE files
.idea
./.env
# .env

# Express config
PORT=3001

# JWT config
JWT_SECRET="[email protected]" # You should change this value to anything else, for instance: "w1Nt3r13r-481O63n15t-C1v1l153-1r4T3"
JWT_EXPIRATION="7d" # Stands for 7 days - we will issue JWTs with a expiration time of 7 days.

# MongoDB config
MONGO_URI="mongodb://127.0.0.1:27017/express-jwt-auth" # You may also paste this value in MongoDB Compass to quickly connect to your database.
./.env.example
# .env.example - We will commit this file to git - it is standard to commit an example, empty, environment file

# Express config
PORT=

# JWT config
JWT_SECRET=""
JWT_EXPIRATION=""

# MongoDB config
MONGO_URI=""

We need to change package.json so that we use nodemon when developing.

Additionally, we have also added the "engines" object at the end to specify which Node.js and npm versions should be used for this project.

./package.json (note that we did not add comments in package.json, because the JSON standard does not allow comments)
{
  "name": "express-jwt-authentication-authorization",
  "version": "0.0.0",
  "private": true,
  "scripts": {
    "start": "node ./bin/www",
    "dev": "nodemon ./bin/www"
  },
  "dependencies": {
    "bcrypt": "5.1.0",
    "cookie-parser": "1.4.6",
    "debug": "4.3.4",
    "dotenv": "16.0.3",
    "express": "4.18.2",
    "http-errors": "2.0.0",
    "jade": "0.29.0",
    "jsonwebtoken": "9.0.0",
    "mongoose": "6.8.2",
    "morgan": "1.10.0",
    "nodemon": "2.0.20"
  },
  "engines": {
    "npm": ">=8.0.0",
    "node": ">=16.0.0"
  }
}

We will create a new file called ".npmrc", which will enforce the Node.js version allowed to install node_modules:

# .npmrc
engine-strict=true

Last but not least, we will add dotenv to app.js:

./app.js
// app.js

// require('dotenv').config() must always be the first line in app.js so that the environment variables
// are loaded before anything else
require('dotenv').config()

var createError = require('http-errors');
var express = require('express');
var path = require('path');
var cookieParser = require('cookie-parser');
var logger = require('morgan');

var indexRouter = require('./routes/index');
var usersRouter = require('./routes/users');

var app = express();

// view engine setup
app.set('views', path.join(__dirname, 'views'));
app.set('view engine', 'jade');

app.use(logger('dev'));
app.use(express.json());
app.use(express.urlencoded({ extended: false }));
app.use(cookieParser());
app.use(express.static(path.join(__dirname, 'public')));

app.use('/', indexRouter);
app.use('/users', usersRouter);

// catch 404 and forward to error handler
app.use(function(req, res, next) {
  next(createError(404));
});

// error handler
app.use(function(err, req, res, next) {
  // set locals, only providing error in development
  res.locals.message = err.message;
  res.locals.error = req.app.get('env') === 'development' ? err : {};

  // render the error page
  res.status(err.status || 500);
  res.render('error');
});

module.exports = app;

We are now ready to start our application, let's do so by running:

npm run dev

The output should look like this:

Valentin-MacBook-Pro:express-jwt-authentication-authorization valentin$ npm run dev

> [email protected] dev
> nodemon ./bin/www

[nodemon] 2.0.20
[nodemon] to restart at any time, enter `rs`
[nodemon] watching path(s): *.*
[nodemon] watching extensions: js,mjs,json
[nodemon] starting `node ./bin/www`

Your application is now running and can be accessed by navigating with your browser to http://localhost:3000

What have we done so far?

So far we have setup our project to use an environment file to store secrets and configuration values and enabled auto-restart for our Express Application in development.

Understand what we are working with

If you'd like to, you may skip this section as its purpose is to give a little more context on Express.

The Express Application that express-generator creates has some modules and features that we do not need for the purpose of this tutorial, but for the sake of simplicity and sticking to our goal, creating an Express Authentication Application, we will choose to ignore them.

Let's analyze app.js
// app.js

// require('dotenv').config() must always be the first line in app.js so that the environment variables
// are loaded before anything else
require('dotenv').config()

var createError = require('http-errors');
var express = require('express');
var path = require('path');
var cookieParser = require('cookie-parser');
var logger = require('morgan');

In the snippet above we've added dotenv to load the .env file, and the rest of the requires are part of the original app generated by app.js.

var indexRouter = require('./routes/index');
var usersRouter = require('./routes/users');

var app = express();

// view engine setup
app.set('views', path.join(__dirname, 'views'));
app.set('view engine', 'jade');

app.use(logger('dev'));
app.use(express.json());
app.use(express.urlencoded({ extended: false }));
app.use(cookieParser());
app.use(express.static(path.join(__dirname, 'public')));

app.use('/', indexRouter);
app.use('/users', usersRouter);

In the snippet above we require 2 routers for our app, we then create the Express Application, apply Middleware, which will intercept requests coming into our API, and perform some specific actions, and then we map the 2 routers we imported to their respective routes.

The two routes are accessible under / and /users, which means you may access them in your browser as:

  • http://localhost:3000/ (which redirects to http://localhost:3000)

  • http://localhost:3000/users

Once you analyze the files imported on the 2 first lines, you will find that the response defined in those controllers is what you're receiving in your browser.

Why doesn't ./routes/users.js router have a route called /users, but instead it has one called /?

That is because, in app.js, we defined that everything in ./routes/users.js must be mapped to /users, which means that the route is actually /users/, which coincides with /users.

If we were to define a new route as /me, then it would be accessible as /users/me.

Finally, the last part of app.js defines error handling, which we will leave as it is.

Connecting to the database

In order to connect to the database, we will create a new folder called database, and inside it we will create a connect.js file:

// ./database/connect.js

const mongoose = require('mongoose');

// This will remove a deprecation warning regarding the upcoming Mongoose v7
mongoose.set('strictQuery', true);

async function connect() {
  try {
     // MONGO_URI is set in .env and is loaded in app.js by the dotenv package
    await mongoose.connect(process.env.MONGO_URI);
    // Success
    console.log(`Successfully connected to database ${ process.env.MONGO_URI }`);
  } catch(error) {
    // Log error
    console.log(`The following error has occurred: ${ error }`);
    // Stop the server
    process.exit(1);
  }
}

module.exports = {
  connect,
}

And then we just need to change app.js like so:

// ./app.js

// app.js

// require('dotenv').config() must always be the first line in app.js so that the environment variables
// are loaded before anything else
require('dotenv').config()
require('./database/connect').connect();

var createError = require('http-errors');
var express = require('express');
var path = require('path');
(...) the rest of the code stays unchanged

Done, if you save all changes, the application should now restart automatically, and you should see something similar to this in your terminal:

[nodemon] restarting due to changes...
[nodemon] starting `node ./bin/www`
Successfully connected to database mongodb://127.0.0.1:27017/express-jwt-auth

Sign up a user

In order to create a new User, we need to first define the database schema for Users, for MongoDB.

First, we will create the User Model. In the database folder we will create a new folder called models, with the 2 following files inside it:

// ./database/models/User.js

const { Schema, model, } = require('mongoose');

const User = new Schema({
  email: { type: String, required: true, trim: true },
  password: { type: String, required: true, trim: true },
}, {
  timestamps: true, // this will make it so the objects have the createdAt and updatedAt fields
});

module.exports = model('User', User);

And the second one:

// ./database/models/index.js

const User = require('./User.js');

module.exports = {
  User,
}

Last but not least, update connect.js like so:

// ./database/connect.js

(...)
    await mongoose.connect(process.env.MONGO_URI);
    // Load all models
    require('./models/index.js') // <------- This line is new!
    // Success
(...)

Let's create our sign up route

In this section we will start using Postman. If you have not set up Postman yet, please do so now.

We have created a working Postman Collection which you may use whenever we mention it: https://documenter.getpostman.com/view/15450146/2s8Z6yXDD1

You will need to click "Run in Postman" and then choose to Import the collection, so that you will be able to edit the request body and headers.

We will start off by creating a new router file and including it in app.js:

// ./routes/authentication.js

const express = require('express');
const router = express.Router();

// Controller for POST /authentication/register
router.post('/register', async function(req, res) {
  res.status(200).json({
    message: "Hello World"
  })
});

module.exports = router;

And then:

// app.js

(...)
var cookieParser = require('cookie-parser');
var logger = require('morgan');

var indexRouter = require('./routes/index');
var usersRouter = require('./routes/users');
var authenticationRouter = require('./routes/authentication');

(...)
app.use(express.static(path.join(__dirname, 'public')));

app.use('/', indexRouter);
app.use('/users', usersRouter);
app.use('/authentication', authenticationRouter);

(...)

You may now try "1 - Sample Post Request" from our Postman Collection, the result of the request will be:

{
    "message": "Hello World"
}

Next, we will do the following:

  • we will add validation for email and password

  • we will check the database for an account that may already exist for the given email address

  • if a user already exists, we will return an error

  • if a user doesn't exist, we will encrypt the password and then save the user

  • (optional) we may also return a JWT if we intend to have an automatic sign in after sign up functionality

We have added everything from the list above in authentication.js. The comments in the code should help you correlate everything with what is happening:

// ./routes/authentication.js

const express = require('express');
const router = express.Router();
const bcrypt = require('bcrypt');
const jwt = require('jsonwebtoken');
const { User } = require('../database/models');

// We will use a Regular Expression to validate the email address
// For advanced input validation, please use a library such as Joi: https://joi.dev/
const validateEmailRegex = /^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-][email protected][a-zA-Z0-9-]+(?:\.[a-zA-Z0-9-]+)*$/;

// Controller for POST /authentication/register
router.post('/register', async function(req, res) {
  try {
    // Get the email and password from the request body
    const { email, password } = req.body;

    // Validate email
    if (!email || !validateEmailRegex.test(email)) {
      return res.status(400).json({
        message: 'Please enter a valid email address',
      });
    }

    // Validate password
    if (!password || password.length < 8) {
      return res.status(400).json({
        message: 'Please enter a password of at least 8 characters',
      });
    }

    // Check if the user already exists
    const user = await User.findOne({ email: email }).select({ _id: 1, });

    // If the user already exists, return an error
    if (user) {
      return res.status(400).json({
        message: 'A user with this email already exists',
      });
    }

    // Any error thrown by these lines will be caught by the catch block
    const salt = await bcrypt.genSalt(10);
    const hashedPassword = await bcrypt.hash(password, salt);

    // If the user does not exist, create a new user
    const newUser = new User({
      email: email,
      password: hashedPassword,
    });

    await newUser.save();

    // (Optional) Create a JWT token for the user
    const token = jwt.sign({ id: newUser._id, }, process.env.JWT_SECRET, {
      expiresIn: process.env.JWT_EXPIRATION,
    });

    return res.status(200).json({
      message: 'User created successfully',
      token,
    });
  } catch(error) {
    // In Production mode, you should log all errors to a logging service such as Sentry
    // For the purpose of this tutorial, we will log to the console
    console.log(error);
    return res.status(400).json({
      message: 'An internal error occurred',
    });
  }
});

module.exports = router;

You may now go to Postman and try "2 - Sign up". The result of this API call will be similar to this one:

{
    "message": "User created successfully",
    "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjYzYWRlNzRjN2Y5ODBhYmNjZDRhZTJiMiIsImlhdCI6MTY3MjM0MTMyNCwiZXhwIjoxNjcyOTQ2MTI0fQ._0pHO__WAeLFes7ROkj8GtgNVsMGE0wga5PpRVAbkfA"
}

Which means that a new User has been created in the database.

If you have installed MongoDB Compass, you may now open it. When prompted to enter the Database URI, you may paste "mongodb://127.0.0.1:27017/express-jwt-auth", which is the same value as the one in your .env file.

Next, you should be able to find a database called "express-jwt-auth" and, when navigating to it, a Collection called "users".

Once you open it, you will find the User that you have just created.

Now, you may call "2 - Sign up" again, using Postman, and you will receive the following error message:

{
    "message": "A user with this email already exists"
}

Which means that our validation is working correctly, preventing duplicate email addresses from being saved for new users.

Sign in a user

In order to sign in a User, we need to:

  • make sure a user with the given email address exists

  • make sure that the given password matches

  • if at least one of the two criteria above is not met, return a vague error message to discourage vulnerability to an Enumeration Attack

  • if both criteria are met, return the JWT

Let's get to coding:

// ./routes/authentication.js

const express = require('express');
const router = express.Router();
const bcrypt = require('bcrypt');
const jwt = require('jsonwebtoken');
const { User } = require('../database/models');

// We will use a Regular Expression to validate the email address
// For advanced input validation, please use a library such as Joi: https://joi.dev/
const validateEmailRegex = /^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-][email protected][a-zA-Z0-9-]+(?:\.[a-zA-Z0-9-]+)*$/;

(...) - the previous controller remains unchanged. Note that "module.exports = router" must be at the end of the file.

// Controller for POST /authentication/login
router.post('/login', async function(req, res) {
  try {
    const { email, password } = req.body;

    if (!email || !validateEmailRegex.test(email)) {
      return res.status(400).json({
        message: 'Please enter a valid email address',
      });
    }

    if (!password || password.length < 8) {
      return res.status(400).json({
        message: 'Please enter a password of at least 8 characters',
      });
    }

    // Fetch the user from the database
    const user = await User.findOne({ email: email }).select({ _id: 1, password: 1, });

    // If the user does not exist, return an error
    if (!user) {
      return res.status(400).json({
        message: 'Incorrect email or password', // The reason why the error message is vague is because
      });
    }

    // Check if the entered password matches the password in the database
    const isPasswordValid = await bcrypt.compare(password, user.password);

    if (!isPasswordValid) {
      return res.status(400).json({
        message: 'Incorrect email or password',
      });
    }

    const token = jwt.sign({ id: user._id, }, process.env.JWT_SECRET, {
      expiresIn: process.env.JWT_EXPIRATION,
    });

    return res.status(200).json({
      message: 'Signed in successfully',
      token,
    });
  } catch(error) {
    console.log(error);
    return res.status(400).json({
      message: 'An internal error occurred',
    });
  }
});

module.exports = router;

Now we should be able to run "3 - Sign in - Wrong Credentials", which is a request that will fail to sign in the user, because the password is wrong:

{
    "message": "Incorrect email or password"
}

If you now run "4 - Sign in - Success", the user will successfully be signed in:

{
    "message": "Signed in successfully",
    "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjYzYWRlNzRjN2Y5ODBhYmNjZDRhZTJiMiIsImlhdCI6MTY3MjM0MjYzNSwiZXhwIjoxNjcyOTQ3NDM1fQ.vkMEBMu7y-sHemLz170oNCvz1qe6zXd48kCGXFbZYbc"
}

If you have modified the request body in your imported Collection, when you signed up the User, you will need to adjust the following requests in the Collection to match your credentials.

Confirming a user's account

In order to Confirm a User's Account we will need to generate a unique link that needs to be sent to user's registered email address.

In this tutorial, we will stop short of sending the email, but we will give you a recommendation on how you can send an email. You will find the suggestion in the code snippet for authentication.js below.

Let's begin

First, we need to add a new value to our environment file:

# .env

# Express config
PORT=3001

# JWT config
JWT_SECRET="[email protected]" # You should change this value to anything else, for instance: "w1Nt3r13r-481O63n15t-C1v1l153-1r4T3"
JWT_EXPIRATION="7d" # Stands for 7 days - we will issue JWTs with a expiration time of 7 days.

# MongoDB config
MONGO_URI="mongodb://127.0.0.1:27017/express-jwt-auth" # You may also paste this value in MongoDB Compass to quickly connect to your database.

# Frontend config
FRONTEND_URL="http://localhost:3000" # This is the URL of the frontend application that will consume this API.

Don't forget to also add the new variable "FRONTEND_URL" to .env.example - it is essential when working in a team that you always keep the .env.example up to date.

Then, we need to adjust the /authentication/register route controller, like so:

// ./routes/authentication.js

(...)    

    await newUser.save();

    // This is where we should generate a JWT token that allows the user to validate their account
    const validationToken = jwt.sign({ email: email }, process.env.JWT_SECRET, {
      expiresIn: '1d', // The activation token will expire in 1 day, therefore the activation link will also expire
    });

    // Create a link that sends the user to the Frontend application that is consuming this API
    const validationLink = `${ process.env.FRONTEND_URL }/authentication/validate/${ validationToken }`;

    // Send the user an email
    /*
    As mentioned, this is not covered in this tutorial, but we'd like to give you an idea of what you can do.
    Our suggestion is to use Sendgrid, it will be enough for a learning experience: https://www.npmjs.com/package/@sendgrid/mail
    If you wish to go to production, there's also paid plans which are reasonably priced.
    You may set up Sendgrid as instructed in their documentation, in the npmjs.com link above,
    and then you can send the "validationLink" to the user's email address.
     */

    // (Optional) Create a JWT token for the user
    const token = jwt.sign({ id: newUser._id, }, process.env.JWT_SECRET, {
      expiresIn: process.env.JWT_EXPIRATION,
    });

(...)
Important

Since the token's expiration time is 1 day, you will need to also implement the functionality to "Resend confirmation email".

We will leave this as a take home exercise for you as it is fairly simple, and you should have the required knowledge from the code we have developed so far.

Resetting a user's password

In order to allow Resetting a User's Password you will need to:

  • have an endpoint to generate a reset password link, and send it to the user using their email address

  • have an endpoint that accepts the token and the new password, and updates the User object in the database

Knowing that you have to create those 2 endpoints, you may now leverage everything we've added so far to create them by yourself.

In order to help you create the 2, and "Resend confirmation email" by yourself, we have already created the 3 requests in Postman that suggest what the endpoints must accept.

The 3 are called: "5 - Resend Confirm Account", "6 - Request Reset Password Link" and "7 - Save New Password".

Authorizing a request

We saved the best for last, and that is Authorizing Requests that are coming into the API.

Authorization is required whenever a request is sent to the API for any data that may be restricted to a certain User or Group of Users.

In order to validate that a Request is Authenticated, we need to:

  • create a Middleware, that we will apply to all Protected API Routes

  • decode the token inside the Middleware to check its validity and retrieve the User's id

  • query the database to check if the User still exists in our database

The third point on the list may seem strange, but it is very common for users to delete their account, or for their roles to change, and you have to check that they are still allowed to use protected routes.

Lastly, we will update ./routes/users.js with a route for /users/me, which will return the User object from the database.

./middleware/decodeJWTMiddleware.js
// ./middleware/decodeJWTMiddleware.js

const jwt = require('jsonwebtoken');
const { User } = require('../database/models');

/**
 * Will decode a JWT token from the "Authorization" header
 * If successful, will attach the token's payload on "req.decoded"
 */
const decodeJWTMiddleware = (req, res, next) => {
  // Get the token from the "Authorization" header
  const token = req.headers?.authorization?.replace('Bearer ', '');

  // If the token is not present, return an error
  if (!token) {
    return res.status(401).json({
      status: 'error',
      error: 'You need to log in before viewing this'
    });
  } else {
    // Verify the token
    jwt.verify(token, process.env.JWT_SECRET, async(err, decoded) => {
      if (err) {
        switch(err.name) {
          case 'TokenExpiredError': {
            return res.status(401).json({
              status: 'error',
              error: 'The session has expired. Please re-login.'
            });
          }
          case 'JsonWebTokenError': {
            return res.status(401).json({
              status: 'error',
              error: 'The session is invalid. Please re-login.',
            });
          }
          default: {
            return res.status(401).json({
              status: 'error',
              error: 'There has been an error. Please re-login.'
            });
          }
        }
      }

      // Fetch the user from the database
      // We only select the "_id" field because we want the query to be very fast, and to consume the least amount of
      // bandwidth we can
      const user = await User.findById(decoded.id).select({ _id: 1, });

      // If the user does not exist, return an error
      if (!user) {
        return res.status(401).json({
          status: 'error',
          error: 'The session is invalid. Please re-login.',
        });
      }

      // Attach the decoded token on "req.decoded"
      req.decoded = decoded;

      return next();
    });
  }
};

module.exports = decodeJWTMiddleware;
./app.js
// app.js

(...)

app.use(express.static(path.join(__dirname, 'public')));

const decodeJWTMiddleware = require('./middleware/decodeJWTMiddleware.js');

app.use('/', indexRouter);
// We only need to apply decodeJWTMiddleware for the /users router.
// If we need to only apply it to an individual route from a router, then decodeJWTMiddleware needs to be placed
// before "(req, res) => {" in the route definition
app.use('/users', decodeJWTMiddleware, usersRouter);
app.use('/authentication', authenticationRouter);

(...)
./routes/users.js
// ./routes/users.js

const express = require('express');
const router = express.Router();
const { User } = require('../database/models');

// Controller for GET /users/me
router.get('/me', async function(req, res) {
  try {
    const user = await User.findById(req.decoded.id).select({ _id: 1, email: 1, });

    if (!user) {
      return res.status(400).json({
        message: 'User not found',
      });
    }

    return res.status(200).json({
      message: 'User retrieved successfully',
      user,
    });
  } catch(error) {
    console.log(error);
    return res.status(400).json({
      message: 'An internal error occurred',
    });
  }
});

module.exports = router;

We can now test that the /users/me endpoint is protected by sending the Postman request "8 - Get my profile", and the result will be:

{
    "status": "error",
    "error": "The session is invalid. Please re-login."
}

That is because the JWT that you are sending for this request is the one that we actually added when writing this article, and since you have your own local database, you probably don't have users with the same ids as we do.

Therefore, what you need to do is to edit the Postman request's Authorization Header.

To do so, you need to:

  • Run "4 - Sign in - Success" and copy the token from the response body

  • Select "8 - Get my profile"

  • Go to the "Authorization" tab

  • Replace our token with your token, by pasting it in the Token field

Once you've done these steps, you may Send the request, and the response will be similar to the following:

{
    "message": "User retrieved successfully",
    "user": {
        "_id": "63ae006df358a4f0d2cbfb5e",
        "email": "[email protected]"
    }
}

That's it, we're done!

We have successfully implemented an API with Express that allows a consumer Frontend Application to use it to allow users to:

  • Sign in (Login)

  • Sign up (Register)

  • Confirm their account

  • Reset their password & Set a new password

  • Access private routes, using the JWT they receive when they Sign in

Closing thoughts and further reading

I hope this article will help you create amazing things!

This article focused on the practical side of implementing a JWT Authentication & Authorization, but if this is your first time working on an API like this one, then I'd strongly recommend the following further reading:

Thank you for reading our article!