How to implement JWT authentication with the MERN stack

Aaqil Ruzzan
12 min readFeb 23, 2024

--

Implementing JWT (JSON Web Token) authentication within the MERN stack is a common practice for securing web applications by ensuring that each request to the server is authenticated. This method provides a secure way to verify the identity of users and allow access to protected routes in your application.

I recommend you read my initial article regarding JWTs if you do not have a theoretical idea about how all of this works. https://medium.com/@aaqil.ruzzan/what-is-jwt-and-what-does-it-do-10d99e283c29

Prerequisites:

A basic knowledge of the first three prerequisites will also suffice.

  1. Understanding of MERN Stack: Knowledge of MongoDB for the database, Express.js for the backend framework, React.js for the frontend library, and Node.js for the runtime environment.
  2. Familiarity with RESTful APIs: Understanding how to create and interact with RESTful services.
  3. JavaScript/TypeScript Knowledge: Comfortable with JavaScript or TypeScript for backend and frontend development.
  4. Environment Setup: Node.js and MongoDB installed on your machine, along with a code editor (e.g., Visual Studio Code).

Installation Requirements:

  • Node.js: Ensure Node.js is installed to run your server-side code.
  • MongoDB: Have MongoDB installed and running for your database needs.
  • Express.js: Use npm install express to set up your Express server.
  • Mongoose: Use npm install mongoose to interact with MongoDB.
  • bcrypt: Install with npm install bcrypt for hashing passwords.
  • jsonwebtoken: Use npm install jsonwebtoken for creating and verifying JWT tokens.
  • validator: Install with npm install validator for validating inputs like emails and passwords.
  • React: Set up your React frontend with create react app or vite(recommended).
  • axios: Use npm install axios for making HTTP requests from your React frontend.

If you feel like you’re ok with all of the above, let’s get started!

I’ll leave you free to go about structuring your files in the project as you prefer. But if you want a reference feel free to check out the GitHub project where I integrated these codes. https://github.com/aaqilruzzan/Smart-home-prototype

Signup Function (Express Backend)

import { Request, Response } from "express";
import user from "../../models/user";
import bcrypt from "bcrypt";
import jwt from "jsonwebtoken";
import validator from "validator";

// create token with user id
const createToken = (_id: string) => {
// expiresIn is set to 1 day
// JWT_SECRET is a secret string that is used to sign the token
return jwt.sign({ _id }, process.env.JWT_SECRET, { expiresIn: "1d" });
};

// controller function to register users

const registerUser = async (req: Request, res: Response): Promise<void> => {
const body = req.body;

try {
const exists = await user.findOne({ email: body.email });

if (!body.name || !body.email || !body.password) {
throw Error("Please fill all the fields");
}

if (!validator.isEmail(body.email)) {
throw Error("Email is not valid");
}

if (!validator.isStrongPassword(body.password)) {
throw Error("Password is not strong enough");
}

if (exists) {
throw Error("Email already exists");
}

// generate salt and hash password
// salt is a random string that is added to the password before hashing
const salt = await bcrypt.genSalt(10);
const hashedPassword = await bcrypt.hash(body.password, salt);

const newUser = new user({
name: body.name,
email: body.email,
password: hashedPassword,
});

const newentry = await newUser.save();
const token = createToken(newentry._id as string);

res.status(201).json({
name: newentry.name,
email: newentry.email,
token: token,
});
} catch (error) {
// Check if it's a validation error
if (error instanceof Error) {
res.status(400).json({
status: "400 Bad Request",
message: error.message,
});
} else {
// Handle internal server errors
console.error("Internal Server Error:", error);

res.status(500).json({
status: "500 Internal Server Error",
message: "500 Internal Server Error, User not created",
});
}
}
};

The above represents a sample controller function that can be used to signup/ register users including the generation and dispatching of the JWT. I have implemented this using typescript and express js.

Initially, relevant validations are done for the name, email, and password which are the three inputs I take in for registration. I use the validator library here as well.

bcrypt is a password hashing library to ensure attackers won’t be able to figure out passwords even if they get access to the hashed passwords. I use this library to generate a salt which is a random string that is added to the password before hashing. This adds an extra layer of protection and ensures that in case 2 passwords are the same they will be hashed differently.

After saving user details with the hashed password the token is created using the createToken function. The function takes the newly created user id from MongoDB as the payload. The secret string that you defined will be taken to sign the token. I have also included an expiration time in the options object.

Remember to not include anything sensitive in the payload and always import your secret key from an env file.

Now you can send your token to the client with any other information as per your software’s requirements.

Login function (Express Backend)

const loginUser = async (req: Request, res: Response): Promise<void> => {
try {
const login = await user.findOne({
email: req.body.email,
});

if (!login) {
res.status(404).json({
message: "Email not found",
status: "404 Not Found",
});
return;
}

const validPassword = await bcrypt.compare(
req.body.password,
login.password
);

if (!validPassword) {
res.status(400).json({
message: "Invalid password",
status: "400 Bad Request",
});
return;
}

const token = createtoken(login._id as string);

res.status(200).json({
name: login.name,
email: login.email,
token: token,
});
} catch (error) {
res.status(500).json({
status: "500 Internal Server Error",
message: "500 Internal Server Error, User not logged in",
});
}
};

Initially after verifying the existence of the user’s email in the database, i used the bcrypt.compare() method to take in the plain text password entered by the user and the existing hashed password to check if they’re the same. If this step is also successful that means this user exists in the database so just like before a token will be created and returned to the client.

AuthContext hook (React Frontend)

import { createContext, useReducer, useEffect } from "react";

export const AuthContext = createContext();

// reducer function to set the user state based on the action type
export const authReducer = (state, action) => {
switch (action.type) {
case "LOGIN":
return { user: action.payload };
case "LOGOUT":
return { user: null };
default:
return state;
}
};

// AuthContextProvider component to wrap the app with the context provider

export const AuthContextProvider = ({ children }) => {
// state to hold the user object
// dispatch function to update the user object
const [state, dispatch] = useReducer(authReducer, {
user: null,
});

// get the user object from local storage if it exists on mount

useEffect(() => {
const user = JSON.parse(localStorage.getItem("user"));

if (user) {
dispatch({ type: "LOGIN", payload: user });
}
}, []);

return (
<AuthContext.Provider value={{ ...state, dispatch }}>
{children}
</AuthContext.Provider>
);
};

When the user logs in the user state should be updated with the relevant details so the application can use the information to display components accordingly. For example, the user’s name can be displayed in a welcome message on the homepage. To ensure the entire application has access to this state we will use react context and we will use the useReducer hook from react to manage state updates.

The AuthContextProvider component should wrap around your application to provide the context that it returns. The dispatch function is also provided in the context thereby the action type and payload can be provided by other components. For example when the user is logged in dispatch({ type: “LOGIN”, payload: response.data }); where response.data represents user data can be coded to ensure the user state gets updated with the relevant data.

When the user logs in or signs up successfully we store the user information with the token so every time the user comes back there’s no need to keep logging in as long as the token hasn’t expired. To achieve this a useEffect hook is used in this code block to take the user object from the browser’s local storage if one exists. This happens every time the application mounts.

import { AuthContext } from "../context/AuthContext"
import { useContext } from "react"

export const useAuthContext = () => {
const context = useContext(AuthContext)

if(!context) {
throw Error('useAuthContext must be used inside an AuthContextProvider')
}

return context
}

Implement a custom hook like the one on top so other components can use the context with ease.

Signup Form (React Frontend)

import React, { useState } from "react";
import axios from "axios";
import { useAuthContext } from "../../hooks/useAuthContext";
import { useNavigate } from "react-router-dom";
function register() {
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [name, setName] = useState("");
const baseUrl = "http://localhost:3000";
// i have harcoded the baseUrl here for simplicity purposes, in implementation
// ensure to store it in environment variables for security and flexibility.
const { user, dispatch } = useAuthContext();
const [error, setError] = useState("");
const navigate = useNavigate();

if (user) {
// If the user is authenticated, redirect to /home
navigate("/home");
}

const registerUser = async (e) => {
e.preventDefault();

try {
const response = await axios.post(
`${baseUrl}/register`,
{
name: name,
email: email,
password: password,
},
{
headers: {
"Content-Type": "application/json",
},
}
);
// to make it simple i have not encrypted the items stored in local storage
// ensure to use encryption once you get the hang of how all of these things work
// as we have to protect our localStorage data in case of a breach.
localStorage.setItem(
"user",
JSON.stringify({
name: response.data.name,
token: response.data.token,
email: response.data.email,
})
);
alert("user registered successfully");
dispatch({ type: "LOGIN", payload: response.data });
} catch (error) {
console.log(error);
setError(error.response.data.message);
}
setError("");
setEmail("");
setPassword("");
setName("");
};

return (
<form onSubmit={registerUser}>
<div className="bg-black">
<div className="flex justify-center container mx-auto my-auto w-[90vw] h-screen items-center flex-col">
<div className="text-slate-100 items-center">
<div className="text-center pb-1">Welcome!</div>
</div>

<div className="w-full md:w-3/4 lg:w-1/2 flex flex-col items-center bg-gray-300 rounded-md pt-6">
<div className="w-3/4 mb-2">
<input
type="name"
name="name"
id="name"
value={name}
onChange={(e) => setName(e.target.value)}
required
className="w-full py-2 px-4 bg-slate-200 placeholder:font-semibold rounded hover:ring-1 hover:ring-gray-600 outline-slate-500 border-solid border-2 border-slate-300"
placeholder="Name"
/>
</div>

<div className="w-3/4 mb-2">
<input
type="email"
name="email"
id="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
className="w-full py-2 px-4 bg-slate-200 placeholder:font-semibold rounded hover:ring-1 hover:ring-gray-600 outline-slate-500 border-solid border-2 border-slate-300"
placeholder="Email address"
/>
</div>

<div className="w-3/4 mb-2">
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
name="password"
id="password"
class="w-full py-2 px-4 bg-slate-200 placeholder:font-semibold rounded hover:ring-1 hover:ring-gray-600 outline-slate-500 border-solid border-2 border-slate-300"
placeholder="Password"
/>
</div>

<div className="w-3/4 mb-2">
<button
type="submit"
class="py-2 bg-black w-full rounded text-blue-50 font-bold hover:bg-blue-700"
>
SIGN UP
</button>
</div>

{error && (
<div className="w-3/4 mb-2">
<div
class="bg-orange-100 border-l-4 border-orange-500 text-orange-700 p-1 text-[15px] "
role="alert"
>
<p>{error}</p>
</div>
</div>
)}
</div>
<div className="flex justify-center container mx-auto mt-2 text-slate-100 text-sm">
<div className="flex flex-col sm:flex-row justify-between md:w-1/2 items-center">
<div
className="flex text-[15px] hover:text-blue-300 cursor-pointer"
onClick={() => navigate("/login")}
>
Back to login
</div>
</div>
</div>
</div>
</div>
</form>
);
}

export default register;

This is my entire signup form. You can refer to it as you see fit, but in the interest of keeping things relevant, I'm going to only focus on the authentication aspect of the code.

First of all, let’s see the API request I make. I pass in the relevant data in the request body and if it is successful the token is stored in the browser’s localstorage so it can be accessed every time the application restarts as I mentioned earlier. The dispatch function is called here so the user state will be updated with user information for us to use throughout our React application.

The if statement at the beginning of the code ensures that the register component will redirect the user to the home page once the user is authenticated and the state is set.

Login Form (React Frontend)

import React, { useState } from "react";
import axios from "axios";
import { useAuthContext } from "../../hooks/useAuthContext";
import { useNavigate } from "react-router-dom";

function login() {
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const baseUrl = "http://localhost:3000";
const { user, dispatch } = useAuthContext();
const [error, setError] = useState("");
const navigate = useNavigate();

if (user) {
// If the user is authenticated, redirect to /home
navigate("/home");
}

const loginUser = async (e) => {
e.preventDefault();

try {
const response = await axios.post(
`${baseurl}/login`,
{
email: email,
password: password,
},
{
headers: {
"Content-Type": "application/json",
},
}
);
localStorage.setItem(
"user",
JSON.stringify({
name: response.data.name,
token: response.data.token,
email: response.data.email,
})
);
alert("user logged in successfully");
dispatch({ type: "LOGIN", payload: response.data });
} catch (error) {
console.error(error);
setError(error.response.data.message);
}

setError("");
setEmail("");
setPassword("");
};

return (
<form onSubmit={loginUser}>
<div className="bg-black">
<div className="flex justify-center container mx-auto my-auto w-[90vw] h-screen items-center flex-col">
<div className="text-slate-100 items-center">
<div className="text-center pb-1 mb-4">Welcome back!</div>
</div>

<div className="w-full md:w-3/4 lg:w-1/2 flex flex-col items-center bg-gray-300 rounded-md pt-6">
<div className="w-3/4 mb-2">
<input
type="email"
name="email"
id="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
className="w-full py-2 px-4 bg-slate-200 placeholder:font-semibold rounded hover:ring-1 hover:ring-gray-600 outline-slate-500 border-solid border-2 border-slate-300"
placeholder="Email address"
/>
</div>

<div className="w-3/4 mb-2">
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
name="password"
id="password"
className="w-full py-2 px-4 bg-slate-200 placeholder:font-semibold rounded hover:ring-1 hover:ring-gray-600 outline-slate-500 border-solid border-2 border-slate-300"
placeholder="Password"
/>
</div>

<div className="w-3/4 mb-2">
<button
type="submit"
className="py-2 bg-black w-full rounded text-blue-50 font-bold hover:bg-blue-700"
>
LOGIN
</button>
</div>

{error && (
<div className="w-3/4 mb-2">
<div
class="bg-orange-100 border-l-4 border-orange-500 text-orange-700 p-1 text-[15px] "
role="alert"
>
<p>{error}</p>
</div>
</div>
)}
</div>
<div className="flex justify-center container mx-auto mt-2 text-slate-100 text-sm">
<div className="flex flex-col sm:flex-row justify-between md:w-1/2 items-center">
<div className="flex text-[15px]">Forgot password?</div>
<div
className="flex text-[15px] hover:text-blue-300 cursor-pointer"
onClick={() => navigate("/")}
>
Dont have an account? Get Started.
</div>
</div>
</div>
</div>
</div>
</form>
);
}

export default login;

In terms of authentication, there is no difference in my implementation for the login form. Both the login and signup forms send the relevant information to the backend for verification and the response which includes the JWT.

Logout Function (React Frontend)

const logOut = () => {
// remove user from storage
localStorage.removeItem("user");

// dispatch logout action
dispatch({ type: "LOGOUT" });
navigate("/login");
};

The logout function ensures that the browser’s localstorage does not have the user object. This prevents the user’s account from being accessed when the application restarts. Dispatch logout action carries out the duty of nullifying the user state thereby the react application will no longer hold the user information. Finally, as most websites do we navigate the user back to the login page.

How to Protect Your API Routes (Express Backend)

middleware/requireAuth.ts

import jwt, { JwtPayload, Secret } from "jsonwebtoken";
import { Request, Response, NextFunction } from "express";
import user from "../models/user";

// requireAuth middleware is applied to all routes except /login and /register
// to prevent unauthenticated users from accessing protected routes
const requireAuth = async (req: Request, res: Response, next: NextFunction) => {
if (req.path === "/login" || req.path === "/register") {
return next();
}
// verify user is authenticated
const { authorization } = req.headers;

if (!authorization) {
return res.status(401).json({ error: "Authorization token required" });
}

const token = authorization.split(" ")[1];

try {
// verify token
const decodedToken = jwt.verify(
token,
process.env.JWT_SECRET as Secret
) as JwtPayload;
const { _id } = decodedToken;
req.user = await user.findOne({ _id }).select("_id");
console.log("user is authenticated");
next();
} catch (error) {
console.log("Error while authenticating : ", error);
return res.status(401).json({ error: "Request is not authorized" });
}
};

export default requireAuth;

requireAuth middleware is applied to all routes except /login and /register to prevent unauthenticated users from accessing protected routes. Here I verify whether there is an authorization header in the request. The authorization header will contain the token. If it is not present an error is thrown.

Then the token is extracted from the header. We use the split function because our request header will look like this.

headers: { Authorization: `Bearer ${user.token}` },

“Bearer” indicates that this is a type of token used to authenticate the client. So to extract the token only const token = authorization.split(“ “)[1]; is used.

Next, the token is verified by the jwt.verify function. This ensures that the token is not manipulated in any shape or form. The user id is extracted from the decoded token. Remember we appended the user id as the payload before sending it to the client. Then that user id is used to verify the existence of that user in the database.

next() executes the next middleware function in the stack. Allowing the authenticated user to access the relevant routes.

app.use(requireAuth);

Ensure to include this code in your app.ts file or your main file in the backend. It should be there before the routes are configured.

 const getDevices = async () => {
const response = await axios.get(`${baseurl}/devices`, {
headers: { Authorization: `Bearer ${user.token}` },
});
setDevices(response.data.devices);
setLoading(false);
};

Now you can make authorized requests like this.

You should also protect your react routes. To do this have the code below in a useEffect hook in all of your pages.

if (!user) {
navigate("/login");
}

After doing this you’ll notice that if the user state is not present (user is not logged in) your page won't be accessible and there will be a redirect to the login page.

The logout function I showed you before is also important here as it immediately nullifies the user state and redirects the user to the login page allowing no further interaction.

This article hereby covers a full basic overview of JWT authentication with the MERN stack. Feel free to learn more and explore ways to improve authentication and authorization in web applications.

--

--

Aaqil Ruzzan

Software Engineer Intern and Undergraduate writing stuff for both tech and non tech peeps.