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

If you are currently building mini API projects with Express.js, you will have noticed this: anyone can send requests to your API endpoints.

However, real-world APIs don't work that way. You have to log in, create an account, and get an API key, which you'll use to authenticate every request you send.

And that’s exactly what this tutorial is all about. I will show you how to build a secure authentication system using Express, JSON Web Tokens (JWT), Bcrypt, and salt. So users can log in, get a token, and use it to authenticate requests before they can access your API routes.

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

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

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.

Change Express 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();

const Port = process.env.PORT || 5000

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

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

Your terminal should display "The server is running…” when you enter npm start.

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://name:password@clustername.63zhick.mongodb.net/LoginApp?appName=NodeExpressTutorial

Enter fullscreen mode Exit fullscreen mode


javascript

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 './DB/connectDb.js';

const app = new express();

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!"]
    }
})

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.

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: Check your collections in Atlas; you should see your new database and a “user created” message in your terminal.

A img showing database in MongoDB

Step 9: 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
}
Enter fullscreen mode Exit fullscreen mode

2. Payload: This contains the actual information the server needs to know what you can access.

E.g.,

{
username: glory,
id: _id1,
role: admin
}
Enter fullscreen mode Exit fullscreen mode

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 decode and read the content.

3. Signature: This combines the header, payload, and secret key and then hashes them together to create a token.

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

Enter fullscreen mode Exit fullscreen mode

Once the token has been issued, the next step is to verify it to ensure it’s valid and has not been compromised.

The Verification Process

To verify that the generated token is valid, it splits it into 3 parts: header, payload, and signature. Then it takes the same secret key used to generate the token.

After that, it combines the header, payload, and the same secret key to create a new hash. Let’s call it token B, and the first token we generated is token A.

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

How to Implement JSON Web Tokens (JWT) in Express

Step 1: Set up your login controller.


import { user } from "../UserModel/User.js";

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

    const {email, password} = req.body;

    if (!email || !password){
        res.status(400).json({"msg":"Email or password cannot be empty"})
    }

    const findUser = await user.findOne({email}).select('+password')
    if(!findEmail){
        res.status(400).json({"msg":"Email doesn't exist!"})
    }

    res.status(200).json({msg:success  })
}


export {
    Login
}

Enter fullscreen mode Exit fullscreen mode

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

Step 2: Set up your login route.


import { Login } from "../controllers/auth.js";

import express from 'express'
const route = express.Router();

route.post('/login', Login)

export default route

Enter fullscreen mode Exit fullscreen mode

Step 3: Install JWT into your project.

npm install jsonwebtoken

Enter fullscreen mode Exit fullscreen mode

Step 4: Create a new JWT secret in your .env file.

E.g.,

JWT_SECRET =  anystrongsecretvalue
Enter fullscreen mode Exit fullscreen mode

Note: Use any secure secret value

Step 5: Set up your payload and secret key and sign it in your login controller.

import jwt from "jsonwebtoken";

import { user } from "../UserModel/User.js";

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

    const {email, password} = req.body;

    if (!email || !password){
        res.status(400).json({"msg":"Email or password cannot be empty"})
    }

    const findUser = await user.findOne({email}).select('+password')
    if(!findUser){
        res.status(400).json({"msg":"Email doesn't exist!"})
    }

    const Payload ={
        "name": findUser.name,
        "id": findUser._id
    }
    const token = jwt.sign(Payload, process.env.JWT_SECRET, {'expiresIn':'3d'})
   res.status(200).json({success:"true", token:token})

}


export {
    Login
}
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.

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 1: 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 ')){
        res.status(400).json({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) {
        res.status(403).json({success:"False", msg:error})
    }
}

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 2: Test it on Postman to confirm it is working.

An img showing a new token generated from a login request in Postman

We can now use this “authentication” file on any route we want to protect.

For instance, if you have a “getProduct” route and you only want users with a valid token to send a request, here is how you would do it.

Step 3: Import the authentication file (i.e., the name of the file that contains the JWT verification) in the route file.

import authentication from "../middleware/authentication.js";
import { Login, getProduct
 } from "../controllers/auth.js";
import express from 'express'
const route = express.Router();

route.post('/login', Login)
route.get('/products', 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 image showing an unauthorized error message

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

An image showing a success message

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 gets compromised, bad actors can access all our users' passwords, which can lead to severe data breaches, especially for users who use the same password for various 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 hashcodes 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 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!"]
    }
})

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 variable represents the number of times the hashing algorithm is applied.
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 can log 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.

   const isMatch = await findUser.comparePassword(String(password))
    if(!isMatch){
        res.status(400).json({success:true, 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 "../UserModel/User.js";

import jwt from "jsonwebtoken";
const Login = async (req, res)=>{

    const {email, password} = req.body;

    if (!email || !password){
        res.status(400).json({"msg":"Email or password cannot be empty"})
    }

    const findUser = await user.findOne({email}).select('+password')
    if(!findUser){
        res.status(400).json({"msg":"Email doesn't exist!"})
    }

    const isMatch = await findUser.comparePassword(String(password))
    if(!isMatch){
        res.status(400).json({success:true, msg:"Incorrect password."})
    }
    const Payload ={
        "name": findUser.name,
        "id": findUser._id
    }
    const token = jwt.sign(Payload, process.env.JWT_SECRET, {'expiresIn':'3d'})
    res.status(200).json({success:"true", token:token})
}
export {
    Login
}
Enter fullscreen mode Exit fullscreen mode

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

An image showing hashed password 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.

Congratulations on making it this far!

Top comments (0)