How to Securely Authenticate and Authorize Users with Node.js, Express, and MongoDB?

The latest way to create secure authentication and authorization systems in 2024 using Node.js, Express, and MongoDB.

Anjana Madushan
Enlear Academy

--

In the modern web development world, security has become the most critical factor ever since. Especially, if you are dealing with the sensitive data of a user, you must be more careful as a developer of the safety of the user data. In those cases, you must consider 2 factors when you build the application.

  1. User authentication
  2. User authorization

What is the difference between these factors?

Authentication is a process of verifying the user’s identity. We can do this by getting the user’s credentials such as mobile no, email, password, etc. Credentials can vary based on the application.

Authorization is a process of granting and denying access to the specific functionalities of an application based on user roles and permissions.

As you can see, both these factors are essential for a web application, and without properly handling them, your application will face a huge security issue. Therefore, as a developer, it is essential to know how to create robust and secure authentication and authorization systems for software.

In this article, you can learn the process of creating such a system using Nodejs, Express, and Mongo DB.

The core concept that we’ll follow

Authentication Process

Figure 1: Authentication process

As I mentioned before, we do the verification of the user’s credentials. Then we are going to create a Token to give access to the users for use of the functionalities in the system.

Tokens are like digital keys — encoding unique user data for secure, discreet access to functionalities.

Here, we are going to use a JWT token for it. It encodes the JSON data into a signed token which can be shared. We can give the ID of the user, name, or something that can identify the user uniquely as the JSON data to encode into the token.

But do not give sensitive data such as email addresses and passwords to encode. Because tokens are a way of carrying information. So, storing sensitive data in tokens can cause security issues.

After creating a Token, we can set that token inside the HTTP-only cookie. The HTTP-only cookies will be not accessible to the front end. Therefore, frontend users cannot access those cookies. So, your token is in safe hands. No one can get your token data and pretend like you.

Authorization process

Figure 2: Authorization process

When a user wants to use some features after logging into the system, the system will send the cookie that is created for that user to the backend. Then, in the backend, it will check the token in the cookie and decode the token to get the user ID.

Based on the user ID, it will check the user’s role in the Database to access the specific function.

Now you have the initial knowledge regarding the process of the system. So, let’s jump to the building process of the system.

Building a robust authentication and authorization system

Initial setup process

First, you need to check whether you have already installed or not Node JS and Mongo DB in your local environment. If not, you have to install them from their official websites. Also, you can create a MongoDB database from their online platform.

Dependency installation

Open your terminal, navigate to the project directory, and give the following command to initialize your node application. Here, I have used npm as the package manager.

npm init -y

This command will create a package.json file for your application.

Then run the following command to install the required dependencies.

npm i express mongoose dotenv body-parser cookie-parser bcrypt jsonwebtoken

Here are some important dependencies we are going to use.

  • jsonwebtoken — used to parse the cookies attached to the client’s request object.
  • cookie-parser — used to create and verify the JWT tokens.
  • bcrypt –used to hash the passwords.

Structure of the project folders

For this, open your preferred Code editor and create a folder structure. I have created the folder structure as below.

Figure 3: Folder structure

We will use each of these files in the next steps.

.env file=> Here we typically store sensitive and environment-specific configurations as environment varibles. We will store the database link, server port, and JWT secret keys in this file in the next steps.

Creating the express server.

Open your index.js file and add the code below to create your express server.

import dotenv from ‘dotenv’;
import express from ‘express’;
dotenv.config();

const app = express();
const PORT = process.env.PORT || 4000;
app.listen(PORT, () => {
console.log(`The server is running on ${PORT}`);
});

Here, we have imported the express module and set up the basic configuration to create an express server.

Also, we must load environment variables here to get the port no. You can access and use stored environment variables by using <<process.env.PORT>>. Replace “PORT” with your env port variable name.

Then, navigate to your directory and run your server. If you have followed me correctly, you will see your console log message in the terminal.

DB configurations

Now, open your db.js file in the config folder and add the code below to initialize the Mongo DB connection to your app.

import mongoose from “mongoose”;
import dotenv from ‘dotenv’;
dotenv.config();

export const DBConnection = async () => {
try {
mongoose.connect(process.env.link);
const { connection } = mongoose;
connection.once('open', () => {
console.log('Mongo DB is connected successfully!!!')
})
} catch (error) {
console.log(`data base connection error`, error)
}
}

Also, here MongoDB database link is stored in the .env file. So we have to import dot-env and call the environment variable for the DB link.

Then you need to call your DB function from the index.js file like below.

import dotenv from ‘dotenv’;
dotenv.config();
import express from ‘express’;
import { DBConnection } from ‘./config/db.js’;

const app = express();
const PORT = process.env.PORT || 4000;
DBConnection();//call your DB function in here
app.listen(PORT, () => {
console.log(`The server is running on ${PORT}`);
});

If you follow me correctly until now, after you change your index.js file like the above, your terminal will show console logs like below.

The server is running on 5000
Mongo DB is connected successfully!!!

I have defined 5000 as the port no in the env file.

User model creation

Now we have completed the creation of the server and db connection. Now, we should define the Mongo DB schema for a user with properties such as name, email, mobile, password, and role.

import mongoose from "mongoose";

const Schema = mongoose.Schema;

const userSchema = new Schema({
name: {
type: String,
required: true
},
mobile: {
type: String,
required: true
},
email: {
type: String,
required: true
},
password: {
type: String,
required: true,
minlength: 6
},
role: {
type: String,
enum: ["customer", "admin", "manager"],
default: "customer"
},
})

const User = mongoose.model("User", userSchema);

export default User;

We have defined all the properties as required, without each of these details, the user cannot sign up to the system.

Also, in the role properties we have defined the main 3 roles such as admin, manager, and customer. And we set it customer as the default role. It means if no role is specified when creating a new instance, it defaults to “customer”.

Then, we have used the mongoose.modal() method to create the user modal.

Sign Up feature

Open your controller file and create the sign-up function like below. And you must import user modal from the config, and bcrypt.

export const signUp = async (req, res) => {

const { name, mobile, email, password, role } = req.body;
//validation for all the input fields
if (!name || !mobile || !email || !password) {
return res.status(422).json({ message: "All feilds should be filled" })
}
try {
let existingUser;
//chaecking whether user already sign up or not based on the email
try {
existingUser = await User.findOne({ $or: [{ email: email }, { mobile: mobile }] });
} catch (err) {
console.error(err);
}

if (existingUser) {
if (existingUser.email == email) {
return res.status(409).json({ message: "A User is already signUp with this email" })
}
else if (existingUser.mobile == mobile) {
return res.status(409).json({ message: "A User is already signUp with this mobile" })
}
}

const salt = await bcrypt.genSalt(6)
//hashsync is a function that can hasing the password
const hashedpassword = await bcrypt.hash(password, salt);

//creating a new User
const user = new User({
name,
mobile,
email,
password: hashedpassword,
role: role,
});

await user.save();
return res.status(201).json({ message: "Account Creation is success,
Login to your account", User: user })
//sending the new user details with token as a message for the response
} catch (err) {
console.error(err)
return res.status(400).json({ message: "Error in saving user in DB" });
}
}

Here we extract the name, email, mobile, password, and role of the user from the request body. Then we check whether there are any users with the same email or mobile no. If it is there, we return error messages.

If there are not any users with the same email or mobile, we allow users to sign up to the system and store the user details in the db using the save() method.

Here, we have used bcrypt to hash the password provided by the user, and that hashed password will be saved in the db. So, this helps to increase the security and the safety of the data of the user since the database also has the hashed password, not the plain password which is entered by the user.

Middleware to create a token.

Open the middleware.js file and let’s create a function to create a JWT token.

export const CreateToken = (id) => {
return jsonwebtoken.sign({id},process.env.JWTAUTHSECRET,{expiresIn: '60s'})
}

This function takes id as a parameter. And you must import the jsonwebtoken dependency, and User modal from the config.

Also, you must create another environment variable to store the secret key for the JWT token, you can give anything for the secret key.

Here we have used the sign() method in the jsonwebtoken dependency to create a token. As we mentioned above discussion, we’ll pass the user ID to encode into the Token. Also, this token will expire in 60 seconds. You can set the time duration as you want. After the expiration, the token cannot be used to authorize the user.

60 seconds is used for testing purposes as the expiration time, you can change it based on your application.

Login Feature

As we discussed earlier, when a user tries to log in first this function will verify the credentials(email, and password).

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

const { email, password } = req.body;

//checking whether pasword and login fields are filled or not
if (!email || !password) {
return res.status(422).json({ message: "All feilds should be filled" })
}

let loggedUser;

try {
loggedUser = await User.findOne({ email: email });

if (!loggedUser) {
return res.status(404).json({ message: "Email is not found, Check it and try again" })
}
//checking password and compare it with exist user's password in the db
const isPasswordCorrect = bcrypt.compareSync(password, loggedUser.password);
if (!isPasswordCorrect) {
return res.status(400).json({ message: "Invalid password, Check it and try again" })
}
const token = CreateToken(loggedUser._id);

//Create and setting a cookie with the user's ID and token
res.cookie(String(loggedUser._id), token, {
path: "/",
expires: new Date(Date.now() + 1000 * 59),
httpOnly: true,//if this option isn't here cookie will be visible to the frontend
sameSite: "lax"
})

//send this message along with logged user details
return res.status(200).json({ message: "Successfully logged in", User: loggedUser })
} catch (err) {
console.log(err)
}
}

Here, to verify the password, we have to use the compareSync() method in the bcrypt. It will check the plain password that is entered by the user with the hashed password that is stored in the DB. It will return true if the passwords are matched.

Next, it will call the create-token function that is created by passing the logged user’s ID as a parameter to create a token. Then, this created token is added to the HTTP-only cookie. And that cookie will expire after 59 seconds.

After creating the cookie successfully with the token and the logged user’s ID, it will send a response to the client side(front end).

Create Middlewares for Token verification and check user role

Create another function in the middleware file to check the token in the cookie.

export const checkToken = async (req, res, next) => {
try {
const cookies = req.headers.cookie;

if (!cookies) {
return res.status(403).json({ message: "Login first" })
}
const token = cookies.split("=")[1];

if (!token) {
return res.status(403).json({ message: "A token is required" })
}
else {
const decode = jsonwebtoken.verify(token, process.env.JWTAUTHSECRET);
req.userId = decode.id;
next();
}
} catch (err) {
return res.status(401).json({message:"Error in the token checking",err});
}
};

This function will be used to get the cookie from the request header. And it will extract the token from the cookie. Then verify() method of the jsonwebtoken is used to verify the token and decode the user ID from the token.

Next, you should create another function in this file which takes the array of user roles as a parameter.

export const checkRole = (requiredRoles) => async (req, res, next) => {
try {
const convertedRoles = requiredRoles.map(role => role.toLowerCase());
const userId = req.userId;
const user = await User.findById(userId);

const userRole = user.role;
if (!convertedRoles.includes(userRole.toLowerCase())) {
return res.status(403).json({ message: 'You are unauthorized' });
}
next();
} catch (err) {
return res.status(500).json({message:'Authorization error occurred',err});
}
};

This function will be used to grant permission for user roles to use the features in the system. It will check the logged user’s role with the required roles array. If that array has the logged user’s role it will allow the user to use a functionality. If not, it will send the response as 403 Forbidden.

Since we store user Id in the req.userId after decoding user Id from the token, we can use it directly in the checkRole function to obtain logged user’s Id.

These middlewares should be used before a logged user uses a feature in the system.

Log out function

In this function, we must get the token from the req header, extract it to get the token, and then the code verifies it using the secret key and clears the corresponding cookie for logging out the user, using the user ID.

export const logout = (req, res) => {
const cookies = req.headers.cookie;//request cookie from the header

//extracting token from the cookies
const previousToken = cookies.split("=")[1];

//if token is not found return this response
if (!previousToken) {
return res.status(400).json({ message: "Couldn't find token" });
}

//varifying token using secret key from the environmental variables
jsonwebtoken.verify(String(previousToken), process.env.JWTAUTHSECRET,
(err, user) => {
if (err) {
console.log(err);
return res.status(403).json({ message: "Authentication failed" });
//if not verified return this error
}
res.clearCookie(`${user.id}`);
req.cookies[`${user.id}`] = "";
return res.status(200).json({ message: "Successfully Logged Out" });
});
};

Get all users

This function fetches all users from the database and returns a JSON response.

export const getAllUsers = async (req, res) => {
try {
const allusers = await User.find();
if (!allusers) {
return res.status(404).json({ message: "There are not any users" });
}
else {
res.status(200).json({ allusers })
}
} catch (error) {
console.log(error);
return res.status(500).json({ message: "Error in getting the Users" })
}
}

This function does not need to do the authentication and authorization. It was created for demo purposes.

Creating routes

Open your route file and add the code below to create routes for the use of the features.

import express from 'express';
const router = express.Router();
import { signUp, login, logout, getAllUsers }from "../controllers/user.js";
import { checkToken, checkRole } from '../middlewares/middlewares.js';

router.post("/signUp", signUp);
router.post("/login", login);
router.post("/logout", checkToken, logout);
router.get('/', checkToken, checkRole(['admin', 'manager']), getAllUsers);

export default router;

Here we have created an instance of an Express router, allowing the definition of routes for an Express application. And, it includes endpoints for user signup, login, logout, and getAlluser features.

  • The ‘/signUp’ endpoint will allow users to register to the system using the signUp feature.
  • The ‘/login’ endpoint will allow users to log in to the system using the Login feature.
  • Also, you can see that in the logout endpoint(‘/logout’), we have called the checkToken function before, the logout function. Because, we should check the user is authenticated, before allowing a user to log out.
  • Also, in the getAllUser endpoint(‘/’), we have called checkToken, and then the checkRole function with the array parameter which has “admin”, and “manager”. It means this feature can only be accessed by the “admin” and “manager” roles. So, a user who has signed up as a customer cannot use this feature.

So, like the getAllUser function endpoint, if you are creating any other features for this system, you should need to think about whether this feature should allow all the user roles or some specific user roles. If a functionality should be able to use some specific user roles such as getAllUser functionality, you should use the checkRole function after the checkToken function and pass the specific role/s as the array parameter. If the functionality can be accessed by all user roles such as logout functionality, no need to call the checkRole function.

Modifying index.js file

Change your index.js like below.

import dotenv from 'dotenv';
dotenv.config();
import express from 'express';
import bodyParser from 'body-parser';
import { DBConnection } from './config/db.js';
import router from './routes/user.js';
import cookieParser from 'cookie-parser';

const app = express();
const PORT = process.env.PORT || 4000;

app.use(bodyParser.json());//1
app.use(cookieParser());//2

app.use('/auth', router);//3

DBConnection();

app.listen(PORT, () => {
console.log(`The server is running on ${PORT}`);
});

Here, we have to make the below changes.

  • Middleware for parsing JSON in the request body(body-parser).
  • Parse and handle cookies in incoming HTTP requests for improved handling. (cookie-parser)
  • Routing requests starting with ‘/auth’ using the defined router.

We’ve successfully built and secured the system. So, Now you can test it using the Postman.

Conclusion

Creating a secure and robust authentication and authorization system is crucial in a web application. Employing best practices such as password hashing using libraries like Bcrypt ensures that user credentials are stored securely.

Utilizing HTTP-only cookies to store authentication tokens adds a layer of security by preventing client-side access.

Furthermore, the practice of storing sensitive variables in a separate .env file is a way of mitigating the exposure of sensitive information.

You can go through the code of the demo by checking out my GitHub Repo.

Happy coding!

Learn more

--

--