How to Setup User Authentication in Node.js Application

Secure Your Node.js App

Ukpai Udo Precious
17 min readFeb 26, 2024
Photo by author

In modern web development, ensuring secure user authentication is paramount for safeguarding sensitive information and user privacy. Node.js, with its asynchronous and event-driven architecture, has become a popular choice for building robust web applications. However, implementing user authentication in a Node.js application requires careful planning and attention to security best practices.

This article is a comprehensive guide to user authentication in Node.js applications. Whether you’re a seasoned developer looking to refine your authentication workflow or a newcomer eager to learn the ropes, this guide will provide the knowledge and tools necessary to establish a secure authentication system. By the end of this article, you’ll have a solid understanding of the fundamental concepts behind user authentication in Node.js and the practical skills to implement a robust authentication system in your applications.

Note: If you wish to watch a video tutorial instead, check out the in-depth tutorial on YouTube. Whether you’re a visual learner or just want a more interactive way to follow along, this tutorial is perfect for you.

Definition of user authentication and its significance in web security

User authentication is a fundamental process in web security that verifies the identity of users accessing a system or application. It involves validating the credentials provided by users, such as usernames and passwords, to ensure that they are who they claim to be. This verification process is crucial for protecting sensitive data, preventing unauthorized access, and maintaining the integrity of the system.

Without proper authentication mechanisms in place, malicious actors could gain unauthorized access to confidential information, compromise user accounts, and carry out various forms of cyber attacks, such as data breaches, identity theft, and fraud. That is why the significance of user authentication in web security cannot be overstated. Authentication helps establish trust between users and the system, safeguarding user privacy and ensuring that only authorized individuals have access to sensitive resources.

Different authentication methods are used in web applications to verify the identity of users. Each method has its advantages and disadvantages, and the choice of authentication method depends on various factors such as security requirements, user experience, and the nature of the application. Here, I’ll explain some common authentication methods, pros, and cons.

  1. Password-Based Authentication: Password-based authentication is one of the most common methods used to verify the identity of users in web applications. It involves users providing a combination of a username and a secret passphrase, known as a password, to gain access to an account or a system. When a user first creates an account on a website or application, they are prompted to choose a unique username and a password. The password is usually required to meet certain complexity requirements, such as a minimum length and a combination of letters, numbers, and special characters. Once the user selects a password, it is securely hashed and stored in a database on the server. Hashing is a process that converts the plain-text password into a unique string of characters, making it computationally infeasible to reverse-engineer the original password from the hash. When the user attempts to log in, they provide their username and password. The server retrieves the hashed password associated with the provided username from the database. It then hashes the password provided by the user and compares the resulting hash with the stored hash. If the hashes match, the user is granted access; otherwise, the login attempt is denied.

Pros:

  • Familiar to users and easy to implement.
  • It provides a basic level of security when combined with other measures like password hashing and salting.

Cons:

While password-based authentication is widely used due to its simplicity and familiarity to users, it does have some vulnerabilities:

  • Vulnerable to password-related attacks such as brute force, dictionary, and phishing attacks.
  • Users tend to reuse passwords across multiple accounts, increasing the risk of credential-stuffing attacks.

2. Two-Factor Authentication (2FA): Two-Factor Authentication (2FA) is an additional layer of security used to verify the identity of users beyond just a username and password combination. With 2FA, users are required to provide two different forms of identification before gaining access to their accounts or systems. This approach enhances security by adding an extra step to the authentication process, making it more difficult for unauthorized individuals to access accounts even if they have obtained the user’s password.

Here’s how Two-Factor Authentication typically works:

  1. Users begin the authentication process by providing their username and password, just like in traditional password-based authentication.
  2. After successfully entering their username and password, users are prompted to provide a second form of identification. This could be something the user knows, such as a code generated by an authenticator app, a PIN, or the answer to a security question.
  3. Once the user provides this secondary factor, the system verifies its correctness. If the secondary factor is validated successfully, access to the account is granted; otherwise, access is denied.

Pros:

Two-factor authentication provides several security benefits such as

  • Provides an additional layer of security by requiring users to provide a second form of verification, such as a code sent to their mobile device.
  • Helps mitigate the risk of unauthorized access even if passwords are compromised.

Cons:

2FA also has its limitations and considerations, for example,

  • It adds complexity for users, potentially leading to friction during the login process.
  • Implementation may require additional infrastructure and maintenance overhead.

3. Biometric Authentication: Biometric authentication is a method of verifying the identity of individuals based on unique physical characteristics. Instead of using traditional credentials like passwords or PINs, biometric authentication relies on biological traits that are unique to each individual, such as fingerprints, facial features, iris patterns, or voiceprints. The user’s biometric data is initially captured and enrolled in the system during the registration process. This involves scanning or capturing the relevant biometric feature, such as a fingerprint or facial image, and converting it into a digital template. The captured biometric data is processed and converted into a unique digital template using specialized algorithms. This template is securely stored in the system’s database or on the user’s device. When the user attempts to access a protected resource or authenticate themselves, they provide their biometric sample (e.g., fingerprint, facial scan). The system then compares the provided biometric sample with the stored template to verify the user’s identity.

Pros:

Biometric authentication offers several advantages, such as;

  • High level of security by using unique physical characteristics such as fingerprints, facial recognition, or iris scans for authentication.
  • Provides a seamless and convenient user experience, eliminating the need to remember passwords.

Cons:

  • Requires specialized hardware or sensors, limiting its availability and compatibility across different devices.
  • Concerns about privacy and data protection, as biometric data is sensitive and can be subject to misuse or unauthorized access.

4. OAuth (Open Authorization):

OAuth, which stands for Open Authorization, is an open standard and protocol that allows users to grant third-party applications limited access to their resources without sharing their credentials, such as usernames and passwords. OAuth is commonly used for delegated authorization scenarios, where a user wants to grant third-party application access to their resources hosted by a service provider (often referred to as the “resource owner”), such as social media accounts, cloud storage, or email services, without exposing their login credentials to the third-party application.

When a user wants to grant a third-party application access to their resources, the application redirects the user to the OAuth authorization server of the service provider. The user is prompted to log in and authorize the requested access. After the user logs in, they are presented with a consent screen detailing the permissions requested by the third-party application. The user can review the requested permissions and choose whether to grant or deny access. If the user grants access, the authorization server issues an authorization grant, which is a token representing the user’s consent to access their resources. The type of authorization grant issued depends on the OAuth flow being used (e.g., Authorization Code Flow, Implicit Flow, Client Credentials Flow). The third-party application presents the authorization grant to the OAuth authorization server to obtain an access token. The access token is a credential that the application can use to access the user’s resources on behalf of the user. The third-party application presents the access token to the service provider’s resource server when making requests to access the user’s resources. The resource server validates the access token and grants access to the requested resources if the token is valid and authorized.

Pros:

  • Enables secure authentication and authorization for third-party applications without sharing user credentials.
  • Simplifies the login process for users by allowing them to sign in with existing accounts from providers like Google, Facebook, or Twitter.

Cons:

  • Complexity in implementation and understanding the OAuth flow, especially for developers new to the protocol.
  • Dependency on third-party identity providers may introduce additional risks and dependencies.

Overall, choosing the right authentication method involves balancing security requirements with user experience considerations. For example, a multi-layered approach that combines multiple authentication methods can provide enhanced security while minimizing usability challenges for users. While a single-layer approach maximizes usability, but introduces a weaker layer of security.

Setup sign-up route for password-based authentication in Nodejs applications

While there exist various authentication methods such as biometric authentication, two-factor authentication (2FA), and OAuth, this article will focus specifically on setting up password-based authentication in Node.js applications. Password-based authentication remains one of the most widely used methods for verifying the identity of users in web applications, providing a familiar and straightforward approach for users to access their accounts.

The first step to creating a password-based authentication system in Node.js is to initialize the application. To do this, open your terminal or command prompt and navigate to the directory where you want to create your Node.js project. Then, use the following command to initialize a new Node.js application:

npm init 

#OR

npm init -y

#IF YOU WANT TO SKIP THE PROCESS OF PROVIDING DETAILS FOR YOUR APPLICATION

The command above will create a package.json file with default settings ( If you made use of npm init -y )in your project directory, allowing you to manage dependencies and configuration for your Node.js application.

Once the initialization process is complete, you can proceed to install the necessary packages and set up the components required for implementing password-based authentication.

Firstly, you have to install mongoose , mongoose is necessary for managing interactions with MongoDB, providing a robust framework for defining schemas, modeling data, and executing database queries efficiently. Secondly, dotenv, dotenv is crucial for securely managing environment variables, allowing sensitive information such as database connection strings and API keys to be stored outside of the codebase. You also need to install express. It serves as a foundational framework for constructing servers in Node.js, providing robust routing and middleware capabilities. Lastly, You need to install MongoDB version 4.1 to ensure compatibility with the latest features of MongoDB. To install these packages, simply execute the following command in your terminal or command prompt within your project directory:

npm install mongoose dotenv mongodb@4.1 express cors bcrypt

This command will download and install mongoose ,dotenv and mongodb@4.1 along with their dependencies, enabling you to seamlessly integrate MongoDB, manage environment variables, and utilize the latest MongoDB features in your Node.js application. Once the installation process is complete, you can proceed to configure and utilize these packages.

Create three folders in your project directory: api, config, and models , and a .env file. The api folder will serve as the repository for your API routes and controllers, housing the logic responsible for handling incoming requests and generating appropriate responses. . In the config folder, you'll store configuration files essential for configuring your application. The .env file will contain sensitive data such API keys. Lastly, the models folder will contain the data models for your application, defining the structure and behavior of your application's data entities.

Go over to your MongoDB atlas console, and get you connection URI string for Node.js driver application. Create a MONGODB_URI variable inside the .env file with the code below:

MONGODB_URI=MongoDb connnection string

Replace ‘MongoDb connection string’ with your actual connection URI, that you got from MongoDB, it will look like this (mongodb+srv://<Username>:<password>@Cluster.whcxdsd.mongodb.net/?retryWrites=true&w=majority&appName=Verification). It is important that you generate your own connection string.

create a server.js file in the root directory. This file serves as the entry point for your application, where you'll configure and start the server. Use the code below to set up a server running on port 4040 using the Express framework:

const app = require('express')();
const port = 4040;
const bodyParser = require('express').json;
app.use(bodyParser());
const cors = require('cors');
app.use(cors());


app.listen(port, () => {
console.log("Server started running of port 4040")
})

Create a db.js file within the config folder. This file will contain the configuration settings required to connect to your database, ensuring interaction with Mongodb. Use the code below to configure the database connection

require('dotenv').config();
const mongoose = require('mongoose')

mongoose.connect(process.env.MONGODB_URI, {})
.then(()=>{
console.log('DB Connected Successfully !')
}).catch((err)=>console.log(err))

The code above is used to establish a connection to a MongoDB database using Mongoose, while using dotenv package to manage environment variables securely. From the code above the require('dotenv').config(); line of code imports the dotenv module and calls its config() method. By calling config(), the .env file is read, and its key-value pairs are added to the process.env object, making them accessible throughout the application. The const mongoose = require('mongoose')line of code imports the mongoose library, which is an Object Data Modeling (ODM) library for MongoDB and Node.js. Mongoose simplifies interactions with MongoDB databases by providing a schema-based solution for modeling application data.

The the mongoose.connect(process.env.MONGODB_URI, {})line establishes a connection to the MongoDB database specified by the MONGODB_URI environment variable that you created earlier. Finally you logged a success message to the console indicating that the connection was successful, if the connection is successful.

Import the config directory into your server.js file with the code below

require('./config/db');

To define the structure of your application’s data entities, create a User.js file inside the models folder that you created earlier. This file will serve as the data model for representing user information in your database. Use the code below to define a user model with Mongoose:

const mongoose = require('mongoose');

const Schema = mongoose.Schema;

const UserSchema = new Schema({
name: String,
email: String,
password: String,
dateOfBirth: Date
})

const User = mongoose.model('User', UserSchema)

module.exports = User

From the code above, the const UserSchema = new Schema({ ... }); defines a Mongoose schema named UserSchema for the user entity. Inside the schema definition, fields such as name, email, password, and dateOfBirth are specified along with their respective data types (i.e., String, Date). These fields represent the properties that each user document will have in the MongoDB collection. The const User = mongoose.model('User', UserSchema);line compiles the UserSchema into a Mongoose model named User. The mongoose.model() function takes two arguments: the singular name of the collection (in this case, 'User') and the schema to use for documents in that collection (UserSchema). The module.exports = User;exports the User model so that it can be imported and used in other parts of the application.

Create a User.js file within the api folder. This file will contain the logic for handling user-related operations, such as creating new user accounts and signing existing users into their accounts. Use the code below to create a signup route using express:

const express = require('express');
const router = express.Router()
const User = require('./../models/User');
const bcrypt = require('bcrypt')

router.post('/signup', (req, res) => {
let {name, email, password, dateOfBirth} = req.body;

name = name.trim();
email = email.trim();
password = password.trim();
dateOfBirth = dateOfBirth.trim()

if(name == "" || email == "" || password == "" || dateOfBirth == "") {
res.json({
status: "FAILED",
message: "Empty input fields provided! Please provide data for these fields"
})
} else if (!/^[a-zA-Z ]*$/.test(name)){
res.json({
status: "FAILED",
message: "Invalid name pattern entered! Name can only contain letters"
})
} else if (!/^[\w-\.]+@([\w-]+\.)+[\w-]{2,4}$/.test(email)){
res.json({
status: "FAILED",
message: "Invalid email pattern! Please provide a proper email address"
})
} else if (!new Date(dateOfBirth).getTime) {
res.json({
status: "FAILED",
message: "Invalid Date Pattern!"
})
} else if (password.length < 8) {
res.json({
status: "FAILED",
message: "Password is too short! A strong password should be atleast 8 characters long"
})
}else{
// check if the user already exists

User.find({email}).then(result => {
if (result.length) {
//If a user already exists
res.json({
status: "FAILED",
message: "User already exists"
})
} else {
//password encrption
const saltRounds = 10
bcrypt.hash(password, saltRounds).then(hashedPassword => {
const newUser = new User({
name,
email,
password:hashedPassword,
dateOfBirth
})
newUser.save().then(result =>{
res.json({
status: "SUCCESS",
message: "Account created successfully",
data: result
})
}).catch(err =>{
res.json({
status: "FAILED",
message: "An error occured while creating user account"
})
})

}).catch(err => {
res.json({
status: "FAILED",
message: "An error occured while hashing password"
})
})
//Try to create a data collection for the user
}
}).catch(err => {
console.log(err)
res.json({
status: "FAILED",
message: "An error occured"
})
})
}
})

module.exports = router

The code above defines a POST route for /signup using express's Router. This route will handle user registration. Here's a breakdown of the code:

  1. The code starts by importing required dependencies such as express, the User model (from the models folder), and bcrypt for password hashing.
  2. The route handler for the /signup route is defined using router.post(). This handler expects a POST request containing user registration data in the request body.
  3. The incoming user data (name, email, password, dateOfBirth) is trimmed to remove white spaces. Then, the code performs the following validation checks on the data:
  • Checks if any of the required fields are empty.
  • Validates the name to ensure it contains only letters.
  • Validates the email format using a regular expression.
  • Checks if the date of birth is in a valid format.
  • Ensures the password meets the minimum length requirement.

4. After data validation, the code queries the database to check if a user with the provided email already exists. If a user with the same email is found, an error message is sent indicating that the user already exists.

5. If the user does not already exist, the password provided by the user is hashed using bcrypt with a specified number of salt rounds. The hashed password is then stored in the database along with other user details.

6. The code uses Mongoose’s User.find() method to check for existing users and new User() to create a new user instance. It then saves the new user to the database using save().

7. Finally, the router object is exported to make it accessible to other parts of the application.

Import the router object into the server.js file and use it with the code below:

const UserRouter = require('./api/User');

app.use('/user', UserRouter)

The code above imports the router object from ./api/User, The app.use() function mounts the user router onto a specific base URL (/user). This means that any request with a URL starting with /user will be routed to the router object, where further routing and handling will occur.

Go to postman and make a POST request to http://localhost:4040/user/signup. Providing the following data in a json format, name, email, password, dateOfBirth

Setup sign-in route for password-based authentication in Nodejs applications

In this section, we will set up the sign-in route for the application we created. The sign-in route stands as a gateway to your application features, holding the key to unlocking personalized experiences for your users. Building a secure sign-in route requires careful planning, attention to security details, and creativity to craft a comfortable user experience.

Begin by defining a route in the User.js file inside the api folder to handle sign-in requests with the code below:

router.post('/signin', (req, res) => {

})

module.exports = router

This route typically listens for a POST requests sent to the /user/signin route.

You can now capture the user’s credentials submitted via the sign-in form (body params of postman or any other API client), with the code below:

router.post('/signin', (req, res) =>{
let {email, password} = req.body
email = email.trim()
password = password.trim()

From the code above the let {email, password} = req.body; line of code uses object destructuring to extract the email and password properties from the req.body object. By using object destructuring, the email and password variables are assigned the values of the corresponding properties from req.body. After extracting the email and password values from req.body, the code applies the trim() method to remove any whitespace characters from the email and password string.

To verify the user’s credentials against the stored credentials in your application’s database or user store, add the code below to the signin route:

if (email == "" || password == ""){
res.json({
status: "FAILED",
message: "Empty fields"
})
} else {
//check if the user exists
User.find({email}).then(data => {
if(data.length){
const hashedPassword = data[0].password
bcrypt.compare(password, hashedPassword).then((result )=> {
if (result) {
res.json({
status: "SUCCESS",
message: "Signin Successful",
data: data
})
}else{
res.json({
status: "FAILED",
message: "Invalid password entered"
})
}
}).catch((err)=> {
res.json({
status: "FAILED",
message: "An error occured while comparing password"
})
})
} else {
res.json({
status: "FAILED",
message: "Invalid credentials"
})
}
}).catch((err) => {
res.json({
status: "FAILED",
message: "An error occured while checking your credentials"
})
})
}

The code above starts by checking if either the email or password fields are empty by using the logical OR operator. If either field is empty, it sends a JSON response indicating it. If the email and password fields are not empty, the code proceeds to query the database to check if a user with the provided email exists. If a user with the specified email exists in the database, the code retrieves the hashed password stored in the database for that user. It then uses the bcrypt.compare() function to compare the password submitted by the user (in plain text) with the hashed password retrieved from the database. If the passwords match, it sends a JSON response with a success status and a message indicating “Signin Successful”, along with the user data retrieved from the database. If the passwords do not match, it sends a JSON response with a failed status and a message indicating “Invalid password entered”.

Implement rate limiting for password-based authentication system in Node.js

In any web application, rate limiting is a crucial security measure implemented to control the number of requests a user or client can make within a specific timeframe. This restriction helps prevent abuse, misuse, or malicious attacks, such as brute force attacks, by limiting the rate at which requests can be made to certain endpoints or actions. Without rate limiting, malicious actors can launch brute force attacks by repeatedly attempting to guess user credentials. By controlling the flow of incoming requests, rate limiting ensures that server resources are not overwhelmed, maintaining availability and preventing service disruptions.

In this section, I will walk you through the process of implementing rate limiting in Node.js.

In your server.js file, add the code below:

const rateLimit = require('express-rate-limit')

let RateLimit = rateLimit({
max: 3,
windowMs: 60 * 60 * 1000,
message: "You have made too requests from this IP. Please try after one hour"
});

app.use('/user/signin', RateLimit)

The const rateLimit = require(‘express-rate-limit’);line imports the express-rate-limit middleware into your application. This middleware provides functionality to limit the number of requests to a specific route or endpoint within a defined timeframe. A new instance of the rate limit middleware is created using the let RateLimit = rateLimit() and specific configuration options are passed to indicate the maximum number of tries, the time it will take to retry (windowMs), and the message to be sent when users exceed their limit. Then the app.use(‘/user/signin’, RateLimit) line applies the rate limit middleware (RateLimit) to the /user/signin route. This means that all requests to the /user/signin endpoint will be subject to the rate limit configured in the RateLimit middleware.

Conclusion

Prioritizing security in web development is paramount to safeguarding sensitive user data and ensuring the trustworthiness of your application. User authentication serves as a foundational pillar of web security, providing mechanisms to verify the identity of users and control access to resources.

Throughout this article, we have explored various aspects of setting up user authentication in Node.js applications. We began by discussing the significance of user authentication in web security, delving into different authentication methods and their respective pros and cons. From traditional password-based authentication to more advanced techniques like biometric authentication and OAuth, each method offers unique advantages and considerations.

This article has provided a comprehensive guide to setting up user authentication in Node.js applications, emphasizing the importance of security in web development with a solid understanding of authentication principles and practical implementation strategies.

--

--

No responses yet