DEV Community

Cover image for Building an Authentication System With Express JWT: A Step-by-Step Guide
Gloria Tejuosho
Gloria Tejuosho

Posted on

Building an Authentication System With Express JWT: A Step-by-Step Guide

You have built an API with Express.js. Your API functionality works as expected. However, users’ passwords are stored as plaintext in your database, and anyone can send requests to your API endpoints.

In practice, that might get a pass because nothing really is at stake. However, in real-world systems, everything is at stake.

Malicious actors can steal all your users’ passwords if your database gets compromised. They can consistently flood the API with requests, consuming bandwidth and memory, thereby disrupting service.

That is why real-world APIs often require you to create an account, log in, and obtain a token to authenticate every request.

In this guide, I’ll show you how to build a secure authentication system in Express using JWT, Bcrypt, and salt. By the end, you’ll have a secure API that is accessible only to users with valid tokens, and passwords will be securely hashed in your database.

What is in This Guide:

  • Prerequisites
  • Basic Setup for Express Project
  • Basic Database Setup in MongoDB.
  • What is JWT?
  • How to Implement JWT in Express
  • How to Hash a Password with Bcrypt and Salt in Express
  • Conclusion

Prerequisites

You should have:

  • VS Code installed on your device
  • A basic understanding of Express.js

Basic Setup for Express Project

PS: You can skip this section if you already know how to set up a basic Express project.

Step 1: Create a new package.json file in a new folder.

npm init -y
Enter fullscreen mode Exit fullscreen mode

Step 2: Install all necessary packages, including Express, Mongoose, dotenv, and nodemon.

npm install express mongoose dotenv nodemon
Enter fullscreen mode Exit fullscreen mode

Step 3: Change the “type” value in package.json to "module" so we can use ESM and set up the nodemon package to enable auto-restart whenever you make changes to a file.

An image shwing how to change package json type to module

Step 4: Create an index.js file and set up the basic Express app configurations.

import express from 'express'

const app = express();
app.use(express.json())

const Port = process.env.PORT || 5000

const start = ()=>{
    app.listen(Port, ()=>{
        console.log('The server is running ....');

    })
}
start();

Enter fullscreen mode Exit fullscreen mode

Step 5: Enter npm start in the terminal.

Your terminal should display "The server is running…”

Basic Database Setup in MongoDB

PS: You can skip this section if you already know how to set up a database in MongoDB

Step 1: Create a .env file and paste your Cluster connection string.
You should have something like this:

MONGO_URI = mongodb+srv://yourname:yourpasswod@clustername.63zhick.mongodb.net/foldername?appName=clustername

Enter fullscreen mode Exit fullscreen mode

PS: You can check out this tutorial in case you don’t know how to get your Cluster connection string in MongoDB Atlas.

Step 2: Create a new folder with a new file. This is where we will connect our code application to MongoDB Atlas.
E.g Database > connectdb.js

import mongoose from "mongoose"
export const connectdb = (url)=>{
    return mongoose.connect(url)
}
Enter fullscreen mode Exit fullscreen mode

In this code, we used the mongoose.connect() method to connect our app to MongoDB.

Step 3: Establish the MongoDB connection in your index.js file.

import express from 'express'
import 'dotenv/config'
import { connectdb } from './Database/connectdb.js';

const app = express();
app.use(express.json())

const Port = process.env.PORT || 5000

const start = async(req, res)=>{
    try {
        await connectdb(process.env.MONGO_URI)

        app.listen(Port, ()=>{
            console.log('Server is running ....');

         })

    } catch (error) {
        console.log(error);

    }
}
start();
Enter fullscreen mode Exit fullscreen mode

In this code, we imported both “dotenv/config” and “connectdb” files. I.e., the previous file we used to connect our code in step 2.
Then we invoked the connectdb() file by passing our MONGO_URI value in step 1.
However, before we proceed, we must set up our database schema.

Step 4: Create a new folder and file inside. This is where we will set up our database schema.
E.g., userschema > user.js

Step 5: Set up the database's basic fields in the file.

import mongoose from 'mongoose'

import validator from 'validator'

const {Schema} = mongoose
const userSchema = new Schema({

    name:{
        type: String,
        required: [true, "Name must not be empty!"],
        trim: true
    },
    email:{
        type: String,
        required: [true, "Email cannot be empty!"],
        validate:{
            validator:function(value) {
                return validator.isEmail(value)
            },
            message: (props)=>`${props.value} is not a valid email address.`,
        },
        unique: true
    },
    password:{
        type: String,
        required: [true, "Password cannot be empty!"],
        select: false

    }
})
Enter fullscreen mode Exit fullscreen mode

In this code, we imported the Mongoose and the Validator packages.
The validator package ensures that the data users enter meets a specific rule before it is saved to the MongoDB database. In this case, we want to use it to ensure the user enters a valid email address.
Note:
To use “validator,” you must first install it in your package.json file.

npm install validator
Enter fullscreen mode Exit fullscreen mode

Then, we created a userSchema variable that holds the necessary fields, including name, email, and password.

Step 6: Compile a model from the Schema definitions.

export const user = mongoose.model('User', userSchema)
Enter fullscreen mode Exit fullscreen mode

Note that this code is still in the same schema file.

Once we have set up our database connection, the next step is to test it to confirm that the connection has been established and a new database has been created.

Step 7: Create a temporary user in the index.js file to verify that the database has been created.

// import the model at the top of the code.
import { user } from './userschema/user.js';
const start = async(req, res)=>{
    try {
        await connectdb(process.env.MONGO_URI)
        await user.create(
            {
                name: "Glory",
                email:"Test@gmail.com",
                password: '123s'
            }
        )
        console.log("User created");

        app.listen(Port, ()=>{
            console.log('Server is running ....');

         })

    } catch (error) {
        console.log(error);

    }
}
start();

Enter fullscreen mode Exit fullscreen mode

Step 8: Enter npm start in the terminal.

You should get a “User created” message in your terminal.

Step 9: Check your collections in Atlas to confirm your database has been created.

An img showing users data in mongoDB Atlas

Step 10: Remove the “user.create()” line of code once you have confirmed that the database has been created. Otherwise, you will automatically create a new user every time you restart the server.

Once we have set up our database, the next step is to issue a token when a user logs in using a JSON Web Token (JWT).

What is a JSON Web Token (JWT)?

JWT is a token that contains all the information a server needs to verify if you can access an endpoint.

It has 3 major components

1. Header: This contains the metadata information about the token, which includes the algorithm used to sign the JWT and its type.
E.g.,
{
“alg”: “HS256”,
“type”: “jwt”
}

2. Payload: This contains the actual information the server needs to know what you can access.
E.g.,
{
“username”: “glory”,
“id”: “_id1”,
“role”: “user”
}

Note: A payload must not contain sensitive information such as passwords because it’s only encoded, not encrypted. This means it can be decoded, i.e., people can read its content.

However, some users may attempt to tamper with non-sensitive data, such as changing the role from user to admin. This is why we must verify the tokens. I explained how to do this in the next section.

3. Signature: This combines the header, payload, and secret key and then hashes them together to create a signature. This signature is used to verify that the token has not been manipulated.

These 3 components, combined with a dot ‘.', make up a token.

   const token = jwt.sign( Payload, SECRET, { algorithm: "HS256" } ) 
//    Output
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VySWQiOiI2OWI4OGExMjk1YjE3ZDNkZjc2M2U1ZTIiLCJuYW1lIjoiR2xvcmlhIFRlanVvcvIiwicm9sZSI6InNlbGxlciIsImlhdCI6MTc3NDQ3NzAwMiwiZXhwIjoxNzc0NDc3OTAyfQ.caJ79KkHN9lsFM4nnjkw8kLyzo2UUYraG4a1o_tGalE

Enter fullscreen mode Exit fullscreen mode

For instance, if you paste this token into a JWT debugger, it will reveal your header and payload. From there, a malicious user can change the role from user to admin.

However, even if they change the payload, the signature part of the token will remain the same because it can only be created with your secret key. Now, the user’s token consists of a header, a new payload, and the same original signature.

The Verification Process

Since some users may attempt to tamper with the payload, we must verify that the provided token is valid before they can access a route.

To verify the user token, the server splits it into 3 parts: header, payload, and signature.
It takes the header, payload, and the secret key it holds and combines them to create a new signature. Let’s call it "signature B.” Then it takes the signature of the provided token. Let's call it “signature A."

Then it compares both signatures. If they match, the token is valid. If it doesn’t, then it’s not a valid token.

An image showing the verification process in expressjs

How to Implement JSON Web Tokens (JWT) in Express

Step 1: Set up a route that allows users to create a new account.

Step 1a: Create a new controller folder and file.
“controller > authentication.js”

Step 1b: Set up your createUser controller.

import { user } from "../userschema/user.js"

const createUser = async(req, res)=>{
    const {name, email, password, confirm} = req.body
    if(!name || !email || !password || !confirm){
        return res.status(400).json({success:false, msg:"None of these fields must be empty!"})
    }
    if(password !== confirm){
        return res.status(400).json({success:false, msg: "Both passwords must match!"})
    }
    try {
        const createNewUser = await user.create({name:name,email:email, password:password})
        return res.status(201).json({success: true, msg: "User created"})

    } catch (error) {
      res.status(400).json({success:false, msg: "An error occurred"})  
    }
}
export {
    createUser
}
Enter fullscreen mode Exit fullscreen mode

This first if statement checks whether any of these fields (name, email, password, and confirm) are empty. If true, it throws an error message.
The second if checks whether both passwords do not match. If true, it throws an error message.
In the createUser variable, we used the model.create() method to create a new user.

Step 1c: Set up your createUser route.
I.e., create a new route folder and a file.
E.g., route > authentication.js

import express from 'express'

import { createUser } from "../controller/authentication.js";

const route = express.Router();

route.post('/user', createUser)

export default route;

Enter fullscreen mode Exit fullscreen mode

Step 1d: Import your route in the index.js file.

import route from './route/authentication.js';
app.use('/api/v1', route)

Enter fullscreen mode Exit fullscreen mode

Step 1e: Test the createUser route in Postman to confirm if it is working as expected.

An img displaying API response in Postman

Step 2: Set up a route that allows users to log in.

Step 2a: Set up your login controller in the same file you created in step 1a.

const login = async (req, res)=>{
    const {email, password} = req.body;
    if (!email || !password){
        return res.status(400).json({success:false, "msg":"Email or password cannot be empty"})
    }
    try {
        const findUser = await user.findOne({email}).select('+password')

        if(!findUser){
           return res.status(400).json({success: false, "msg":"Email doesn't exist!"})
        }
        return res.status(200).json({success:true, msg:Logged in!})

    } catch (error) {
        res.status(500).json({success:false, msg:"An error occurred"})
    }
}
export {
    createUser,
    login
}

Enter fullscreen mode Exit fullscreen mode

This first if statement checks whether the email and password are empty. If true, it throws an error message.
The findUser variable checks if the provided email address exists and retrieves the corresponding password. If it doesn’t exist, it throws an error message.

Step 2b: Set up your login route.

import express from 'express'
import { createUser, login } from "../controller/authentication.js";
const route = express.Router();

route.post('/user', createUser)
route.post('/login', login)
export default route;

Enter fullscreen mode Exit fullscreen mode

Step 2c: Test the login route in Postman to confirm if it is working as expected.

An img showing API response in Postman

Step 3:Implement JWT in your Express project.

Step 3a: Install JWT.

npm install jsonwebtoken

Enter fullscreen mode Exit fullscreen mode

Step 3b: Create a new JWT secret in your .env file.
E.g.,

JWT_SECRET =  replacewithanystrongsecretvalue
Enter fullscreen mode Exit fullscreen mode

Note: Use a secure secret value

Step 3c: import jsonwebstoken, set up your payload and secret key, and sign it in your login controller.

import jwt from "jsonwebtoken";
const login = async (req, res)=>{
    const {email, password} = req.body;
    if (!email || !password){
        return res.status(400).json({success:false, "msg":"Email or password cannot be empty"})
    }
    try {
        const findUser = await user.findOne({email}).select('+password')

        if(!findUser){
           return res.status(400).json({success: false, "msg":"Email doesn't exist!"})
        }
       const Payload ={
          "name": findUser.name,
          "id": findUser._id
        }
        const token = jwt.sign(Payload, process.env.JWT_SECRET, {'expiresIn':'3d'})
        return res.status(200).json({success:true, token:token})


    } catch (error) {
        res.status(500).json({success:false, msg:"An error occurred"})
    }
}
Enter fullscreen mode Exit fullscreen mode

We imported the JWT package at the top of the file. Then we set up the payload using the value in the findUser variable. I.e., name and ID.

Then we signed the token using the jwt.sign() method and passed the payload, secret key, and expiration time.
The process.env.JWT_SECRET represents your secret value in your .env file.
The expiresIn represents the token validity; in this case, it’s set to 3 days.

Step 4: JWT Verification in Express

Just like I explained earlier, after generating a token, we must verify it to ensure it’s valid before users can access a route.

Step 4a: Create a “middleware” folder and an “authentication.js” file inside it.

import jwt from "jsonwebtoken"

const authentication = (req, res, next)=>{
    const tokenValue = req.headers.authorization;

    if(!tokenValue || !tokenValue.startsWith('Bearer ')){
        return res.status(400).json({success:false, msg:"Missing or incorrect token header."})
    }
    const token = tokenValue.split(' ')[1]
    try {
        const decoded = jwt.verify(token, process.env.JWT_SECRET)
        req.user = {id:decoded.id, name: decoded.name}
        next();
    } catch (error) {
        return res.status(403).json({success:false, msg:"Token is invalid"})
    }
}
export default authentication;

Enter fullscreen mode Exit fullscreen mode

The tokenValue variable retrieves the token generated from the header. (Note: Every time a user wants to access a route, they will pass the token in the header.)

The if statement checks whether the token is missing or the user added “Bearer “ at the beginning of the token value. (You’ll see this in action later.)

In the token variable, we used the split() method to get only the token value. (I.e., excluding the word bearer)

Inside the try-catch block, we used the jwt.verify() method and passed the token value with the secret JWT key. This is used to verify the token.
If it is valid, we retrieve the user ID and name.
Otherwise, if the token is invalid or has expired, it will throw an error.

Step 4b: Test the login route in Postman to confirm it works. You should get a token in the response body.

An img showing API response in Postman

Step 5: How to Access a Route With a Valid Token
Users can now obtain a valid token when they create a new account. However, we are currently not using the token to protect any route.

Here is how to protect a route:

Step 5a: Create a new route.
E.g., “getProduct”

const getProduct = (req, res)=>{
    return res.status(200).json({success:true, data:datas})
}
Enter fullscreen mode Exit fullscreen mode

Note that datas in this code refers to an array of objects imported in the route file.

Step 5b: Import the authentication middleware in the route file.

import express from 'express'
import authentication from '../middleware/authentication.js';
import { createUser, login, getProduct } from "../controller/authentication.js";

const route = express.Router();

route.post('/user', createUser)
route.post('/login',login)
route.get('/product', authentication, getProduct)
export default route;

Enter fullscreen mode Exit fullscreen mode

We imported the authentication file and added it right before the getProduct route.
If you try to access this route without providing a token value, you’ll get this error:

An img showing API error message

However, if you add the token to the headers property before sending the request, you will get a successful response.

An img showing API successmessage

How to Hash a Password with Bcrypt and Salt in Express

We have successfully implemented an authentication system that allows users to log in, receive a token, and use it to access a route. However, it doesn’t end there.

Right now, users' passwords are stored as plaintext in the database. If the database is compromised, bad actors can access all our users' passwords, leading to severe data breaches, especially for users who reuse the same password across different accounts.

This is where password hashing comes in, using Bcrypt.

Bcrypt is a hashing algorithm that is used to hash passwords before they are stored in the database.

Here is how it works: it takes the plain password and uses a hashing function to convert it into hash codes. These hash codes are simply strings of random characters. They cannot be reversed, i.e., no one can decode them to get the actual password, making it a more secure option.

However, hashing passwords with only Bcrypt also makes it susceptible to a Rainbow table attack. This is a password-cracking mechanism that uses a rainbow table to search for the corresponding text of a hash. This rainbow table contains billions of data entries (strings) and their corresponding hashes based on different hashing algorithms. So if a user creates an account with a weak password, they can look up the hashed password in the table to get the equivalent text.
To prevent this, we use Salt.

Salt adds a layer of security to the hashed password by adding a random string of characters before it is stored in the database. So, even if there is a data breach, it’ll be extremely difficult to decrypt the hash.

Here is how to hash a password with Bcrypt and Salt in Express

Step 1: Install the Bcrypt package.

npm install bcrypt
Enter fullscreen mode Exit fullscreen mode

Step 2: Import the Bcrypt package at the top of the user model file, create a salt work factor, and hash the password before saving it in the database.

import mongoose from 'mongoose'
import validator from 'validator'
import bcrypt from "bcrypt"
const SALT_WORK_FACTOR = 10

const {Schema} = mongoose
const userSchema = new Schema({

    name:{
        type: String,
        required: [true, "Name must not be empty!"],
        trim: true
    },
    email:{
        type: String,
        required: [true, "Email cannot be empty!"],
        validate:{
            validator:function(value) {
                return validator.isEmail(value)
            },
            message: (props)=>`${props.value} is not a valid email address.`
        },
        unique: true
    },
   password:{
        type: String,
        required: [true, "Password cannot be empty!"],
        select: false
    }
})

userSchema.pre('save', async function(){
    const user = this;

    if(!user.isModified('password')){
        return;
    }
    const salt = await bcrypt.genSalt(SALT_WORK_FACTOR);
    const hashpassword = await bcrypt.hash(user.password, salt);
    user.password = hashpassword;
})

export const user = mongoose.model('User', userSchema)
Enter fullscreen mode Exit fullscreen mode

The SALT_WORK_FACTOR determines the computational cost of hashing. A value of 10 means the algorithm runs 2¹⁰ (1,024) rounds, making brute-force attacks extremely slow.
Then we used the schema.pre() hook to hash the password before it’s saved.
The if statement checks if the password has been modified; if true, it exits the code.
The salt variable uses the bcrypt.genSalt() to generate a unique salt.
The hashpassword variable uses the bcrypt.hash() function to hash the user's password with the generated salt.
Then we updated the user password with the generated hash.

Step 3: Compare the provided password with the hashed password in the database before a user logs in using the bcrypt.compare() function.

userSchema.methods.comparePassword = async function(candidatePassword) {
    const isMatch = await bcrypt.compare(candidatePassword, this.password)
    return isMatch;
}
Enter fullscreen mode Exit fullscreen mode

In the userSchema.methods.comparePassword() function, we used the bcrypt.compare() method to compare the plaintext password (provided by the user) with the hashed password in the database. Then it returns true if they match.

Step 4: Use the comparePassword() method in the login controller to ensure only users with a valid password can log in.

Note: Add this code before your payload.

   const isMatch = await findUser.comparePassword(String(password))
   if(!isMatch){
        return res.status(400).json({success:false, msg:"Incorrect password."})
    }    
Enter fullscreen mode Exit fullscreen mode

The variable isMatch uses the comparePassword method to compare the provided password with the hashed password. Then, if the password doesn’t match, it throws an error message.

Here is the complete code for the login controller.

import { user } from "../userschema/user.js"
import jwt from "jsonwebtoken";

const login = async (req, res)=>{

    const {email, password} = req.body;
    if (!email || !password){
        return res.status(400).json({success:false, "msg":"Email or password cannot be empty"})
    }
    try {
        const findUser = await user.findOne({email}).select('+password')

        if(!findUser){
           return res.status(400).json({success: false, "msg":"Email doesn't exist!"})
        }
       const isMatch = await findUser.comparePassword(String(password))
       if(!isMatch){
           return res.status(400).json({success:false, msg:"Incorrect password."})
        }    
       const Payload ={
          "name": findUser.name,
          "id": findUser._id
        }
        const token = jwt.sign(Payload, process.env.JWT_SECRET, {'expiresIn':'3d'})
        return res.status(200).json({success:true, token:token})


    } catch (error) {
        res.status(500).json({success:false, msg:"An error occurred"})
    }

}
Enter fullscreen mode Exit fullscreen mode

New users' passwords should be stored like this in your database.

An img showing hashed passwords in MongoDB

Conclusion

You have successfully implemented a secure authentication system in your Express project using JWT. Now, users have to log in and provide a valid token before they can send requests to API endpoints.
You also learned how to secure user passwords in your database by using Bcrypt and Salt to hash passwords.
Cheers!

Top comments (1)

Some comments may only be visible to logged-in visitors. Sign in to view all comments.