MERN Stack Authentication: Building a Secure System from Scratch (Part 1)
Discover the Process of Building a Custom Authentication System with MERN Stack
Introduction
In this tutorial series, we'll discuss authentication, a must-have system for almost any application. We will implement a secure authentication system where the user is assigned a short-lived access token, which is included in every authorization request, and a refresh token used to renew the access token. Additionally, we'll implement a feature for resetting the forgotten password via email. To accomplish this, we'll use the MERN stack, Redux Toolkit as well as Material UI for styling. So, roll up your sleeves, open your code editors, and get ready for action! This series of tutorials will be lengthy but extremely interesting ๐
A small note: in a real application, I would typically use a pre-built authentication system provided by services such as Auth0 or Firebase. They offer a way to save a significant amount of time and ensure that no security issues or bugs are introduced. However, I believe most web developers should understand the intricacies of working with an authentication system. And that's precisely what this tutorial aims to achieve!
Token Storage Security Concerns
Firstly, I'd like to talk about the issue of storing authentication tokens. Most of us are familiar with the common methods of storing tokens, such as local storage, session storage, or cookies.
I won't delve deeply into the pros and cons of each method, but let's briefly break down the potential security vulnerabilities that these methods might expose. If you're curious to learn more about the pros and cons of each token storage method, I came across this super helpful article that you might find useful.
Local storage and session storage are both susceptible to Cross-Site Scripting (XSS) attacks, a form of cyber threat where an attacker injects malicious JavaScript code into a website. As a result, access tokens stored within local storage and session storage can be easily retrieved.
Cookies are generally secure against XSS attacks when using
httpOnly
andsecure
cookies, as they prevent access via JavaScript. However, if you store the access token within cookies, attackers can still send an HTTP request to the server that automatically includes your cookies. Consequently, they do not need to access them directly.Cookies are also susceptible to CSRF attacks, but this risk can be heavily mitigated by using the
sameSite
attribute.
The main idea is that storing access tokens in these storage types isn't safe. But, we can avoid this problem by keeping the access token in memory. You might wonder, "What if the access token is lost?" Well, we have a refresh token to renew it!
Authentication Process Overview
The idea behind this approach is to store the access token in memory, while the refresh token is stored inside a httpOnly
cookie. This way, whenever the access token is gone or expired, we can obtain a new one by sending a request along with our cookies.
Here is an illustration that demonstrates how the system works:
So, let's break down the authentication process:
The user submits a login request to the server, providing their credentials.
Upon successful login, the server returns an access token and a refresh token. The access token is stored in memory (e.g., Redux storage), while the refresh token is sent via
httpOnly
cookies.When the user sends a request, the server validates the access token.
If the access token is valid, the server responds with the requested data.
If the access token is invalid or expired, the server decodes the refresh token sent via cookies, retrieves user information based on the refresh token, validates the refresh token, and refreshes both tokens. Finally, the server sends the requested data to the user.
If you're looking for a more in-depth understanding, I'd suggest checking out this video by Ben Awad. It's super helpful!
Feeling a bit overwhelmed? No worries! Once we start putting this into practice, it'll become much simpler, I promise ๐
Getting Started
I must warn you that this project requires some familiarity with React and related tools, such as Redux Toolkit, understanding JWT tokens, setting up a server in Express, and so on.
Since this is a full-stack project, we will divide it into two distinct folders: frontend
and backend
. First, let's establish the basic setup for the root directory, which we will build upon later.
Run the following set of commands:
npm init -y
It's a matter of personal preference, but I prefer using ESLint. If you'd like to use it for linting, execute the following command:
npm init @eslint/config
In order to configure the ESLint setup for both frontend and backend, follow these steps:
When editing the .eslintrc.json
file:
{
"env": {
"browser": true,
"es2021": true,
"node": true,
"commonjs": true
},
"extends": [
"eslint:recommended",
"plugin:@typescript-eslint/recommended"
],
"overrides": [
],
"parser": "@typescript-eslint/parser",
"parserOptions": {
"ecmaVersion": "latest",
"sourceType": "module"
},
"plugins": [
"@typescript-eslint",
"react"
],
"rules": {
"no-unused-vars": "warn",
"quotes": ["error", "double"],
"semi": ["error", "always"],
"indent": ["error", 4],
"react/function-component-definition": "off",
"react/react-in-jsx-scope": "off",
"react/prop-types": "off",
"@typescript-eslint/no-var-requires": "off"
}
}
Alrighty, now we're all set to begin! ๐ Let's kick things off with the backend since it takes care of the core authentication logic. Plus, it'll be easier to work on the frontend once we know what to expect from the server.
Backend
Setting up the backend
The backend will consist of a simple Express server running on Node.js, using MongoDB as the chosen database. First, we will set up the npm packages:
mkdir backend
cd backend/
npm init -y
After initializing the package.json
file, we can add the express
package:
npm install express
Following the server setup, we will proceed to install dotenv
for managing environment variables from a .env
file located in the root directory as well as cors
to enable CORS:
npm install dotenv cors
Next, we will install TypeScript. Simply run the following commands:
# Install TypeScript
npm i -D typescript @types/express @types/node
# Generate tsconfig.json
npx tsc --init
The default output directory is specified by the outDir
parameter in compilerOptions
. For convenience, let's change it to dist
. As a result, our tsconfig.json
file will be configured as follows:
{
"compilerOptions": {
"outDir": "./dist",
"target": "es2016",
"module": "commonjs",
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"strict": true,
"skipLibCheck": true
}
}
The last task remaining is setting up the database. Since we are building a MERN stack application, our obvious choice for the database is MongoDB. Let's install Mongoose, an object modeling tool for MongoDB and Node.js:
npm install mongoose
After that, we need to set up our database locally. The installation and setup vary based on the OS you are using, so I won't cover it in this blog. If you don't know how to set up MongoDB, check their guide for Windows, macOS, and Linux.
Alright, we've finished setting everything up. We'll install additional required packages as we move forward. Congratulations! ๐
Initializing the Server
Time to write code! We'll create the src
directory, which will serve as the main directory containing all our source code. Then, we'll place an index.ts
file inside it with the following content:
// index.ts
import express from "express";
import dotenv from "dotenv";
dotenv.config({ path: "../.env" });
import mongoose from "mongoose";
import cors from "cors";
// Creating Express application instance
const app = express();
// Defining the port to run the server on
const PORT = process.env.PORT || 5000;
// Defining the MongoDB connection string
const MONGODB = process.env.MONGODB_URI || "";
// CORS options to be used by the application
const corsOptions = {
// Origin URLs allowed to make requests to this server
origin: ["http://localhost:5173"],
// Including cookies in CORS request
credentials: true,
};
app.use(express.json());
app.use(express.urlencoded({ extended: true }));
app.use(cors(corsOptions));
// Connecting to MongoDB
mongoose.connect(MONGODB).then(() => {
app.listen(PORT);
}).catch((error) => {
console.log(error.message);
});
As you can see, we'll be using some environment variables in our code. To use them as well, you'll need to create a .env
file in the project's root directory. Inside, you'll need to provide the port number and connection link to your MongoDB:
// .env
PORT=5000
MONGODB_URI=mongodb://127.0.0.1:27017
Keep in mind that your MongoDB URI may vary based on your specific setup. Additionally, in order to allow the code compilation without restarting the server after each modification, we'll be using concurrently
and nodemon
:
npm install -D concurrently nodemon
After installing the dependencies, update the scripts within the package.json
file:
"scripts": {
"build": "npx tsc",
"start": "node dist/index.js",
"server": "concurrently \"npx tsc --watch\" \"nodemon -q dist/index.js\""
},
Here, we'll execute the "server" command to run the program in development mode. We can use the "build" and "start" scripts to compile and run the code in production.
Defining the User Model
Let's move on to defining our database model. To do this, we'll simply create a folder named models
.
Since the functionality of our app is limited to authentication, we will have only one model representing the User. Let's define this model in a file called User.ts
:
// models/User.ts
import { Schema, model} from "mongoose";
// Define the interface for the User
export interface IUser {
email: string;
password: string;
firstName: string;
lastName: string;
role: "admin" | "user";
refreshToken: string;
}
// Creating a Mongoose schema for the User
const userSchema = new Schema<IUser>({
email: { type: String, required: true, unique: true },
password: { type: String, required: true },
firstName: { type: String, required: true },
lastName: { type: String, required: true },
role: { type: String, enum: ["admin", "user"], default: "user" },
refreshToken: String
});
export default model<IUser>("User", userSchema);
Notice that we use the role
field to determine a user's role. We'll also store the refresh token along with the user data, which helps in renewing the access token.
To learn more about creating models in MongoDB with Mongoose and TypeScript, refer to this.
Implementing Authentication: Login, Register, Logout
Alright, here comes the toughest part of our blog. So far, we've done an excellent job setting up our Express server and creating the database model for the application. Now, it's time to write the logic for the main functionalities: login, register, and logout.
To do this, we'll create a separate folder called controllers
and place a file named auth.ts
inside. Before we start coding, let's review the plan for our auth system to help you understand it better:
First and foremost, I'd like to separate the logic dedicated to generating JWT tokens, as this procedure will be repeated multiple times throughout our program.
To achieve this, we'll install the jsonwebtoken
library, which is an implementation of JWT tokens. Execute the following command in the console:
npm i jsonwebtoken
Next, we can develop the separate function responsible for generating the token:
// controllers/auth.ts
const generateJWT = (userId: string, TOKEN_SECRET: string, expiryTime: string) => {
return jwt.sign(
{ id: userId },
TOKEN_SECRET,
{ expiresIn: expiryTime }
);
};
We are passing three parameters: the userId
as the payload, the secret key for signing the token, and the expiration time. These arguments will vary for access and refresh tokens. Then, we call the sign()
method, which returns the JWT as a string.
If you're interested in learning more about the secret key and how JWT fundamentally operates, I recommend reading the accepted answer for this topic on StackOverflow.
For now, it's important to know that we have to create those secret keys ourselves. We'll keep them safe in the .env
file, and make sure that refresh and access tokens have different keys. Just add 2 new lines to your file to update it:
// .env
ACCESS_TOKEN_SECRET=KEY_FOR_ACCESS_TOKEN
REFRESH_TOKEN_SECRET=KEY_FOR_REFRESH_TOKEN
- Register
Let's get started with setting up our auth system by working on the user registration process. When we receive a POST request with all the registration details about the users, we'll just parse those details, save them in the database, and then issue access and refresh tokens to our newly registered users. No worries, I'll walk you through the code and explain it step by step. Here's the code we'll be using:
// controllers/auth.ts
export const register = async (req: Request, res: Response, next: NextFunction) => {
try {
// Getting the user's data from the body of request
const userToRegister: IUser = req.body;
if (!userToRegister.email || !userToRegister.password) {
return res.status(400).json({ message: "Email and password are required"});
}
const existingUser = await User.findOne({ email: userToRegister.email });
if (existingUser) { // Email must be unique for each user
return res.status(400).json({ message: "User with given email already exists" });
}
// Hashing the user's password
const hashedPassword = await bcrypt.hash(userToRegister.password, 10);
// Creating instance of new user
const newUser = new User<IUser>({
...userToRegister,
password: hashedPassword
});
// Generating both refresh and access tokens
const refreshToken = generateJWT(newUser._id.toString(), process.env.REFRESH_TOKEN_SECRET || "", "1d");
const accessToken = generateJWT(newUser._id.toString(), process.env.ACCESS_TOKEN_SECRET || "", "5s");
newUser.refreshToken = refreshToken;
await newUser.save(); // saving the new user
// Creating user object without password to send as response
const { password, ...newUserWithoutPassword } = newUser.toObject();
// Sending the HTTP-only cookies that contain refresh token
res.cookie("jwt", refreshToken, { httpOnly: true, secure: true, sameSite: "strict", maxAge: 24*60*60*1000 });
// Sending the data of the new user and access token as response
res.status(201).json({
user: newUserWithoutPassword,
accessToken
});
} catch (error) {
next(error);
}
};
Alright, bear with me. Essentially, what we're doing is:
1. Checking if the user provided all the necessary details for signing up, as well as ensuring the email is unique.
2. Storing the user's hashed password in the database. We'll use a library called bcrypt
for hashing the passwords, which you can install by running the command:
npm i bcrypt
We'll use its hash()
method along with any options that can be passed to it. If you want to learn more about the hash()
method, check out the documentation.
3. After that, we're generating a new instance of a User model, which is pretty straightforward.
4. Next, we're signing the JWT tokens with the user's ID, secret key stored in the .env
file, and the expiration date. The access token will be renewed every minute, and the refresh token will be valid for 1 day. This means that if the user does not engage with the program for 1 day, they will need to re-login.
5. Then, I am sending the refresh token inside an HTTP-only cookie, along with the user's details (excluding the password) and the access token.
And that's it for registration. If you've grasped this, it will be much easier for you to understand the login and logout processes.
- Login
Let's move on to the login process. Here is the code:
// controllers/auth.ts
export const login = async (req: Request, res: Response, next: NextFunction) => {
try {
const { email, password } = req.body;
if (!email || !password) {
return res.status(400).json({ message: "Email and password are required" });
}
// Retrieving the user in the database by email
const user = await User.findOne({ email });
if (!user) {
return res.status(404).json({ message: "Invalid email" });
}
// Checking whether passwords match
const isPasswordCorrect = await bcrypt.compare(password, user.password);
if (!isPasswordCorrect) {
return res.status(400).json({ message: "Invalid password" });
}
// Generating both refresh and access tokens
const refreshToken = generateJWT(user._id.toString(), process.env.REFRESH_TOKEN_SECRET || "", "1d");
const accessToken = generateJWT(user._id.toString(), process.env.ACCESS_TOKEN_SECRET || "", "5s");
user.refreshToken = refreshToken;
const loginUser = await user.save();
// Sending the HTTP-only cookies that contain refresh token
res.cookie("jwt", refreshToken, { httpOnly: true, secure: true, sameSite: "strict", maxAge: 24*60*60*1000 });
// Sending the user's data and access token as response
res.status(200).json({ user: loginUser, accessToken });
} catch (error) {
next(error);
}
};
The process is pretty similar to registration, don't you think? The main difference is that we don't need to create a new user, but just verify if the user has provided the right credentials, like their email and password, and then issue new tokens.
- Logout
And the last part of the functionality is logout. However, in order to make it work, we need to be able to parse the Cookie
header sent along with the request. This is not possible by default, but it can be implemented using cookie-parser
.
We'll install it by running:
npm i cookie-parser
Next, we need to add two more lines of code to our index.ts
file:
...
import cookieParser from "cookie-parser";
...
app.use(express.json());
app.use(express.urlencoded({ extended: true }));
app.use(cors(corsOptions));
app.use(cookieParser());
...
Alright, during the logout process, we'll remove the user's refresh token from both their cookies and the database. That's why we need a library to parse cookies. Here's the code for this:
export const logout = async (req: Request, res: Response, next: NextFunction) => {
try {
const cookies = req.cookies; // parsing cookies
if (!cookies?.jwt) {
return res.status(200).json({ message: "Logged out"});
}
const refreshToken = cookies.jwt;
const logoutUser = await User.findOne({ refreshToken });
if (!logoutUser) {
res.clearCookie("204", { httpOnly: true, sameSite: "strict", secure: true });
return res.status(200).json({ message: "Logged out"});
}
// Removing the refresh token from the database
logoutUser.refreshToken = "";
await logoutUser.save();
// Clearing the user's cookies
res.clearCookie("204", { httpOnly: true, sameSite: "strict", secure: true });
return res.status(200).json({ message: "Logged out"});
} catch (error) {
next(error);
}
};
This one is quite straightforward; all we need to do is to remove the refresh token, ensuring that the user must log in again after logging out, which is logical.
Conclusion
We just wrapped up the first part of our tutorial ๐
We explored the world of authentication and its complexities. Together, we built our very own login, registration, and logout system on the backend using Node.js, Express, and MongoDB. Great job!
But wait, there's more! In the next series, we'll tackle a crucial aspect of our authentication system: refreshing expired access tokens. We'll also cover other exciting topics like defining routes in the Express server, role-based authorization, and more. Once we're done mastering the backend, we'll move on to the frontend and create a fully functioning full-stack application using the MERN stack. Stay tuned! ๐ฅ
If you encounter any difficulties along the way, you can refer to the complete source code available on my GitHub repository. If you have any questions or need assistance, don't hesitate to reach out to me. Feel free to contact me, even if it's not related to this project ๐
If you enjoyed this article, remember to follow me on Twitter, where I share daily updates. And feel free to connect with me on LinkedIn.