DEV Community

Cover image for Mastering Secure Authentication in Node.js: Login/Logout with bcrypt.js and JWT
Tuhin Poddar
Tuhin Poddar

Posted on

16 3 4 3 3

Mastering Secure Authentication in Node.js: Login/Logout with bcrypt.js and JWT

Imagine you’re building a web application that's about to launch. You've carefully designed the user interface, added exciting features, and made sure everything runs smoothly. But as the launch date gets closer, a nagging concern starts to worry you—security. Specifically, how to ensure that only the right users can access the right parts of your application. This is where authentication comes in.

Authentication is the process of verifying who a user is, and it's a critical aspect of web development. In the vast digital landscape, ensuring that users can securely log in and log out of your application is paramount. One slip, and your app could be vulnerable to attacks, putting user data at risk.

Introduction

In this article, we will explore secure authentication in Node.js, using bcrypt.js to hash passwords and JWT tokens to manage user sessions. By the end, you'll have a solid understanding of how to implement a strong login/logout system, keeping your users’ data safe and secure.

So, let’s embark on this journey to build a bulletproof authentication system, starting from setting up our environment to securing our routes with JWT. Ready to lock down your Node.js app? Let’s get started.

Setting Up Your Node.js Project Environment

First, initialize your Node.js project with npm init -y, which creates a package.json file with default settings. Next, install essential packages: express for setting up the server, mongoose for managing MongoDB, jsonwebtoken for handling JWT tokens, bcryptjs for hashing passwords, dotenv for environment variables, cors for enabling Cross-Origin Resource Sharing, cookie-parser for parsing cookies. Finally, add nodemon as a development dependency to automatically restart the server when code changes.

1.`npm init -y`
2.`npm install express mongoose jsonwebtoken bcryptjs dotenv cors cookie-parser`
3.`npm install nodemon -D`

Enter fullscreen mode Exit fullscreen mode

Now modify the package.json file. Add scripts like my code and type.

"scripts": {
    "dev": "nodemon backend/index.js",
    "start": "node backend/index.js"
  },
"type": "module",

Enter fullscreen mode Exit fullscreen mode

Basic Server Setup

Next, we'll set up a basic Express server. Create a file named index.js . This code initializes Express and creates an instance of the application. We'll then define a route for the root URL ("/") to handle incoming HTTP GET requests. After that, we'll start the server on port 8000, allowing it to listen for incoming requests.

import express from "express";
const app = express();

app.get("/", (req, res) => {
  res.send("Server is ready");
});

app.listen(8000, () => {
  console.log("Server is running on PORT 8000");
});
Enter fullscreen mode Exit fullscreen mode

Setting Up Basic Authentication Routes

Now, we will create a folder named 'routes' and in that folder we will make make a new file named authRoute.js and paste the below code to see basics of routes.

In this code snippet, we're setting up routes for different authentication endpoints using Express. First, we import the express library and create a new router instance. Then, we define three GET routes: /signup, /login, and /logout, each responding with a JSON object indicating that the respective endpoint was hit. Finally, we export the router instance as the default export, making it available for use in other parts of the application.

import express from "express";

// Create a new Express router instance
const router = express.Router();

// Define a GET route for the signup endpoint
router.get("/signup", (req, res) => {
  // Return a JSON response indicating that the signup endpoint was hit
  res.json({
    data: "You hit signup endpoint",
  });
});

// Define a GET route for the login endpoint
router.get("/login", (req, res) => {
  // Return a JSON response indicating that the login endpoint was hit
  res.json({
    data: "You hit login endpoint",
  });
});

// Define a GET route for the logout endpoint
router.get("/logout", (req, res) => {
  // Return a JSON response indicating that the logout endpoint was hit
  res.json({
    data: "You hit logout endpoint",
  });
});

// Export the router instance as the default export
export default router;
Enter fullscreen mode Exit fullscreen mode

Now change the index.js adding the auth route to test out your end points.

import express from "express";
import authRoute from "./routes/authRoutes.js";
const app = express();

app.get("/", (req, res) => {
  res.send("Server is ready");
});

app.use("/api/auth", authRoute);

app.listen(8000, () => {
  console.log("Server is running on PORT 8000");
});
Enter fullscreen mode Exit fullscreen mode

Now, you can test it in your browser...but I will use Postman for its convenience. You can test all the end points like this.

login testing

Similarly you can see the other routes like Logout and SignUp.

So, Our basic app is ready...now make it a robust and a proper authentication system.

Creating the User Model with Mongoose Schema

Now, first ready our mongoDB database. To do that make a folder Model and under that a file User.js and in this file add Mongoose schema and model for a User in a mongoDB database. The schema includes fields for username, fullName, password, and email, each with specified data types and constraints like uniqueness and required status. The password field also has a minimum length of 6 characters.

import mongoose from "mongoose";

// Define the User schema with various fields and their data types
const userSchema = new mongoose.Schema(
  {
    // The unique username of the user
    username: {
      type: String,
      required: true,
      unique: true,
    },
    fullName: {
      type: String,
      required: true,
    },
    // The password of the user (min length: 6)
    password: {
      type: String,
      required: true,
      minLength: 6,
    },
    // The email of the user (unique)
    email: {
      type: String,
      required: true,
      unique: true,
    },
  },
  { timestamps: true }
);

// Create the User model based on the userSchema
const User = mongoose.model("User", userSchema);

// Export the User model
export default User;
Enter fullscreen mode Exit fullscreen mode

Connecting to MongoDB with Mongoose

Now let's connect to our database. We'll create a folder named db and inside it, a file called connectDB.js. In this file, we'll define an asynchronous function connectMongoDB that tries to connect to a MongoDB database using Mongoose. It gets the database connection string from the MONGO_URI environment variable. If the connection is successful, it logs a success message with the host name. If it fails, it logs the error and exits the process with a status code of 1. The function is exported for use in other parts of the application.

import mongoose from "mongoose";

const connectMongoDB = async () => {
  try {
    // Use the mongoose.connect() method to connect to the database
    // using the MONGO_URI environment variable
    const conn = await mongoose.connect(process.env.MONGO_URI);
    console.log(`MongoDB connected: ${conn.connection.host}`);
  } catch (error) {
    console.error(`Error connection to mongoDB: ${error.message}`);
    process.exit(1);
  }
};

export default connectMongoDB;
Enter fullscreen mode Exit fullscreen mode

Now to use MONGO_URI we have to make it in .env file. Here I have used local mongoDB setup connection string. If you want then you can also use mongoDB atlas.

MONGO_URI=mongodb://localhost:27017/auth-test
Enter fullscreen mode Exit fullscreen mode

Signup function

Now make the signup function. For this 1st make a folder controller and there file authController.js

export const signup = async (req, res) => {
  try {
    const { fullName, username, email, password } = req.body; // Destructuring user input

    // Regular expression for email format validation
    const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
    if (!emailRegex.test(email)) {
      return res.status(400).json({ error: "Invalid email format" });
    }

    // Checking if username is already taken
    const existingUser = await User.findOne({ username });
    if (existingUser) {
      return res.status(400).json({ error: "Username is already taken" });
    }

    // Checking if email is already taken
    const existingEmail = await User.findOne({ email });
    if (existingEmail) {
      return res.status(400).json({ error: "Email is already taken" });
    }

    // Validating password length
    if (password.length < 6) {
      return res
        .status(400)
        .json({ error: "Password must be at least 6 characters long" });
    }

    // Generating salt and hashing the password
    const salt = await bcrypt.genSalt(10);
    const hashedPassword = await bcrypt.hash(password, salt);

    // Creating a new user instance
    const newUser = new User({
      fullName,
      username,
      email,
      password: hashedPassword,
    });

    // Saving the new user to the database
    await newUser.save();

    // Generating JWT token and setting it as a cookie
    generateTokenAndSetCookie(newUser._id, res);

    // Sending success response with user data
    res.status(201).json({
      _id: newUser._id,
      fullName: newUser.fullName,
      username: newUser.username,
      email: newUser.email,
    });
  } catch (error) {
    // Handling errors
    console.log("Error in signup controller", error.message);
    res.status(500).json({ error: "Internal Server Error" });
  }
};
Enter fullscreen mode Exit fullscreen mode

First, it extracts fullName, username, email, and password from the request body. It validates the email format using a regular expression, returning a 400 status if the format is invalid.

Next, the function checks if the username or email already exists in the database. If either is taken, a 400 status with an error message is returned. It also ensures the password is at least 6 characters long, sending another 400 status if this condition isn't met.

The password is then securely hashed using bcrypt. A new User instance is created with the provided data and saved to the database.

After saving, the function generates a JWT token, sets it as a cookie, and returns a 201 status with the user's ID, full name, username, and email. If any errors occur, they are logged, and a 500 status is sent with an "Internal Server Error" message.

To make this function active you have to import these

import { generateTokenAndSetCookie } from "../utils/generateToken.js";
import User from "../Model/User.js";
import bcrypt from "bcryptjs";
Enter fullscreen mode Exit fullscreen mode

Notice something? a new thing called generateTokenAndSetCookie...lets see its code...make a folder utils and there generateTokenAndSetCookie.js.

// Import the jsonwebtoken library
import jwt from "jsonwebtoken";

// Define a function that generates a JWT and sets it as a cookie
export const generateTokenAndSetCookie = (userId, res) => {
  // Use the jsonwebtoken library to sign a JWT with the user ID and a secret key
  // Set the expiration time of the token to 15 days
  const token = jwt.sign({ userId }, process.env.JWT_SECRET, {
    expiresIn: "15d",
  });
  res.cookie("jwt", token, {
    maxAge: 15 * 24 * 60 * 60 * 1000, //MS
    httpOnly: true, // prevent XSS attacks cross-site scripting attacks
    sameSite: "strict", // CSRF attacks cross-site request forgery attacks
    secure: process.env.NODE_ENV !== "development",
  });
};
Enter fullscreen mode Exit fullscreen mode

The **generateTokenAndSetCookie **function creates a JWT and stores it in a cookie for user authentication.

JWT Generation:

The function uses the jsonwebtoken library to create a JWT. It signs the token with the user's ID and a secret key (JWT_SECRET from the environment variables), setting it to expire in 15 days.

Setting the Cookie:

The token is then stored in a cookie on the user's browser. The cookie is configured with several security attributes:

  • maxAge: Sets the cookie's lifespan to 15 days.
  • httpOnly: Ensures the cookie is not accessible via JavaScript, protecting against XSS (Cross-Site Scripting) attacks.
  • sameSite: "strict": Prevents CSRF (Cross-Site Request Forgery) attacks by restricting the cookie to be sent only with requests from the same site.
  • secure: Ensures the cookie is only sent over HTTPS if the environment is not development, adding an extra layer of security.

So this function ensures that the user's session is both secure and persistent, making it a crucial part of the authentication process.

Here we have to add another environment variable JWT_SECRET in .env. You can add any type of mix of number and string like this.

MONGO_URI=mongodb://localhost:27017/auth-test
JWT_SECRET=GBfChCMY8MB7wnymkWhtoD3cRlA1CC5e6iWSBmnuaSg
Enter fullscreen mode Exit fullscreen mode

Now our signUp function is complete..so make its route now.

import express from "express";
import { signup } from "../controllers/authController.js";

const router = express.Router();

router.post("/signup", signup);

export default router;
Enter fullscreen mode Exit fullscreen mode

ok, now let’s modify our index.js Here we added some new imports. dotenv: Loads environment variables securely from .env; express.json(): Parses incoming JSON requests; express.urlencoded({ extended: true }): Parses URL-encoded data; cookieParser: Handles cookies for JWT tokens; connectMongoDB(): Connects to MongoDB for data storage; Routes: /api/auth manages signup, login, and logout.

Here is updated code of index.js

import express from "express";
import dotenv from "dotenv";
import authRoute from "./routes/authRoutes.js";
import connectMongoDB from "./db/connectDB.js";
import cookieParser from "cookie-parser";

dotenv.config();

const app = express();

app.get("/", (req, res) => {
  res.send("Server is ready");
});

// Middleware to parse JSON request bodies
app.use(express.json()); // for parsing application/json

// Middleware to parse URL-encoded request bodies (if you're using form data)
app.use(express.urlencoded({ extended: true }));

app.use(cookieParser());

app.use("/api/auth", authRoute);

app.listen(8000, () => {
  console.log("Server is running on PORT 8000");
  connectMongoDB();
});
Enter fullscreen mode Exit fullscreen mode

So. now it’s time to test our signup function in Postman. Let’s see if it’s working or not.

So, here is the results.

Signup

Here you can see it’s working properly and you can check it your mongoDB database as well.

database

Login function

Now make the login function. Let’s go again to our authController.js file

export const login = async (req, res) => {
  try {
    const { username, password } = req.body; // Destructuring user input

    // Finding the user by username
    const user = await User.findOne({ username });

    // Comparing passwords using bcrypt
    const isPasswordCorrect = await bcrypt.compare(
      password,
      user?.password || ""
    );

    // If user or password is incorrect, return error
    if (!user || !isPasswordCorrect) {
      return res.status(400).json({ error: "Invalid username or password" });
    }

    // Generating JWT token and setting it as a cookie
    generateTokenAndSetCookie(user._id, res);

    // Sending success response with user data
    res.status(200).json({
      message: "Logged in successfully",
    });
  } catch (error) {
    // Handling errors
    console.log("Error in login controller", error.message);
    res.status(500).json({ error: "Internal Server Error" });
  }
};
Enter fullscreen mode Exit fullscreen mode

The login controller authenticates a user by verifying their username and password. It first searches for the user in the database using the username. If found, it compares the provided password with the hashed password stored in the database using bcrypt. If the username or password is incorrect, it returns an error response. On successful verification, it generates a JWT token, sets it as a cookie using generateTokenAndSetCookie, and responds with a success message, indicating the user is logged in successfully.

Let’s add our login route in authRoutes.js

import express from "express";
import { login, signup } from "../controllers/authController.js";

const router = express.Router();

router.post("/signup", signup);
router.post("/login", login);

export default router;
Enter fullscreen mode Exit fullscreen mode

Let’s test it in Postman.

login
Here you can see it is successfully showing Logged in.

Logout function

Okay. Now the last function i.e. the logout function. Let’s implement this. It’s pretty simple.

export const logout = async (req, res) => {
  try {
    // Clearing JWT cookie
    res.cookie("jwt", "", { maxAge: 0 });
    // Sending success response
    res.status(200).json({ message: "Logged out successfully" });
  } catch (error) {
    // Handling errors
    console.log("Error in logout controller", error.message);
    res.status(500).json({ error: "Internal Server Error" });
  }
};
Enter fullscreen mode Exit fullscreen mode

The logout controller securely logs out a user by clearing the JWT cookie from the client's browser using res.cookie, setting its value to an empty string and its maxAge to 0, ensuring immediate expiration. Upon successful cookie clearance, it sends a success response with a message indicating the user is logged out successfully. If any error occurs during this process, it catches the error, logs it, and returns an Internal Server Error response.

Add this route to our authRoute.js

import express from "express";
import { login, logout, signup } from "../controllers/authController.js";

const router = express.Router();

router.post("/signup", signup);
router.post("/login", login);
router.post("/logout", logout);

export default router;
Enter fullscreen mode Exit fullscreen mode

okay. Let’s test our last feature, if it’s working fine or not.

logout

Oh!…It’s working super fine. 😃😃

So, now our complete backend of this authentication is ready. 🎉🎉

My npm package

If you don't want to code everything yourself and want a quick solution, I have created an npm package called auth0_package. You can get it from here.

Github Repository

You can get my all above code here in this github repo here.

Now your backend application is complete. In the next blog, I will explain how to integrate this with your frontend. So stay tuned for that 😉😉.

Conclusion

In conclusion, implementing secure authentication in a Node.js application is crucial for protecting user data and ensuring that only authorized users can access specific parts of your application. By using bcrypt.js for password hashing and JWT tokens for session management, you can create a robust login/logout system. This approach not only enhances security but also provides a seamless user experience. Setting up a MongoDB database and using Express for routing further strengthens the backend infrastructure. With these tools and techniques, you can confidently launch your web application, knowing that it is well-protected against unauthorized access and potential security threats.

Image of AssemblyAI tool

Transforming Interviews into Publishable Stories with AssemblyAI

Insightview is a modern web application that streamlines the interview workflow for journalists. By leveraging AssemblyAI's LeMUR and Universal-2 technology, it transforms raw interview recordings into structured, actionable content, dramatically reducing the time from recording to publication.

Key Features:
🎥 Audio/video file upload with real-time preview
🗣️ Advanced transcription with speaker identification
⭐ Automatic highlight extraction of key moments
✍️ AI-powered article draft generation
📤 Export interview's subtitles in VTT format

Read full post

Top comments (3)

Collapse
 
ansellmaximilian profile image
Ansell Maximilian

Nice article. Even though I use third party libraries for authentication most of the time, it's always nice to come across articles like these explaining the basics to make sure I still understand how it all works under the hood.

A little suggestion: when you write code blocks you can specify a language identifier for syntax highlighting like

```javascript
const variable = 1;
```
Enter fullscreen mode Exit fullscreen mode
Collapse
 
tuhin114 profile image
Tuhin Poddar

Thank you for your appreciation. I will surely mention the language from next blog. Thank you!

Collapse
 
rockksdb profile image
RocksDB

In Node.js, secure authentication can be achieved using bcrypt.js for hashing passwords and JWT (JSON Web Tokens) for session management. During registration, hash the password with bcrypt, and on login, compare it with the stored hash. If matched, generate a JWT for authentication. Use middleware to protect routes by verifying the JWT, and on logout, clear the token from the client side. This approach ensures secure password storage and efficient session handling.

Cloudinary image

Zoom pan, gen fill, restore, overlay, upscale, crop, resize...

Chain advanced transformations through a set of image and video APIs while optimizing assets by 90%.

Explore

👋 Kindness is contagious

Dive into an ocean of knowledge with this thought-provoking post, revered deeply within the supportive DEV Community. Developers of all levels are welcome to join and enhance our collective intelligence.

Saying a simple "thank you" can brighten someone's day. Share your gratitude in the comments below!

On DEV, sharing ideas eases our path and fortifies our community connections. Found this helpful? Sending a quick thanks to the author can be profoundly valued.

Okay