Skip to main content

Command Palette

Search for a command to run...

How to Implement Session-Based Authentication in Node.js Using Express, Passport.js, and Mongoose

Updated
23 min read
How to Implement Session-Based Authentication in Node.js Using Express, Passport.js, and Mongoose

Authentication is essential for any full-stack web application, but adding it to projects can be daunting. That's where session-based authentication comes in. It helps secure your APIs using a simpler and more understandable approach with sessions and cookies.

In this tutorial, I will guide you through setting up session-based authentication using Express, Mongoose, and Passport.js. I will also show you how to implement authentication for APIs with them.

Getting Started

To continue with this tutorial, you need to be familiar with the following:

  • Basic knowledge of React.js, Express, Node.js, and APIs

  • Beginner-level experience with Mongoose, MongoDB and MongoDB Compass

  • Feel free to also use your favourite code editor and package manager

If you want to view the session implementation source code in this article, check out this GitHub link

What is Session-Based Authentication?

Session-based authentication is a common way of authenticating users. Unlike token-based authentication, session-based authentication works by storing user details on the server and accessing them through the use of sessions and cookies in an easy-to-understand approach.

How Session-Based Authentication Works

  1. User logs in with credentials (like email/password), and the Server verifies those credentials.

    Image illustrating how the user logs in with credentials

  2. If valid, the server creates a session:

    • Usually represented by a unique session ID.

    • This session ID is stored on the server and in a cookie. In this tutorial, we will store our session information in a MongoDB database.

  3. Image illustrating the server creating a session

    The session ID is then sent to the client and typically stored in a cookie. On every request, the client sends the session ID back.

    Image illustrating how the session ID is sent to the client and stored in a cookie

  4. The server checks the session ID, finds the corresponding user, and confirms they’re authenticated.

    Image illustrating the server checking the session ID

    Image illustrating the server checking the session ID

  5. When the user triggers a logout, the browser sends a logout request to the server.

    Image illustrating the user triggering a logout request

  6. After receiving the request, the server deletes the session from both the server and the cookie, invalidating the session information. This action then triggers another request to the browser, indicating that the session has ended.

Image illustrating the server deleting the session

Image illustrating the server deleting the session

Implementing Sessions

This tutorial focuses on session-based authentication with Passport.js, but to use Passport.js, we first need to understand how sessions work and how to implement session-based authentication without using Passport.js.

Let’s get started. Firstly, create a new project folder with your preferred folder name:

mkdir session

After creating the folder, run the following command to add a sample package.json file:

pnpm init

Then, install the following dependencies:

pnpm add express express-session

Implementing Session-Based Authentication

Sessions are part of the Node.js request object, but Express has a package called express-session that helps us set up sessions and configure their cookies. That's what we will use in this article. Create an index.js file in the root folder with the following code:

import express from "express";
import session from "express-session";

const app = express();

app.use(
    session({
        secret: "S3CRET_KEY",
        resave: false,
        saveUninitialized: false,
        cookie: {
            maxAge: 60000 * 60,
        },
    })
);

app.use(express.json());
app.use(express.urlencoded({ extended: false }));
app.listen(4000, () => console.log("Server Running on  PORT: 4000"));

The express-session middleware is used in Express with the .use() method. This method has configuration options that let you define how sessions work in your project. Some of these configuration options include:

  • secret: This is a required option as it signs the session with a secret key, which should be stored in a .env file.

  • resave: This option, which is set to false by default, forces the session to be saved back to the session store even if it hasn't been changed.

  • saveUninitialized: This option forces an uninitialized session to be stored in the session store, meaning the session can be saved even if it wasn't initiated.

  • store: By default, this is set to memory store, but it is used to specify where the session is stored on the server, typically the database URL. We will look at this later in the tutorial.

  • cookie: This configuration option is an object used to control how the session cookie functions. It also has its configuration options, some of which include:

    • maxAge: This defines the duration or lifespan of the session cookie.

    • httpOnly: We won't be using this option for this tutorial. However, if set to true, it stops client-side JavaScript from accessing the cookie with document.cookie. This helps prevent attackers from accessing data stored in the cookie.

Creating The Authentication Routes

Now that we've reviewed how the session is configured in our index.js file, we'll add some API routes to test the session. Create the following API routes with the code snippet below:

const app = express();

// Users
let users = [
    {
        username: "John Doe",
        email: "johndoe@gmail.com",
        password: "john-doe0001",
    },
];

// Register User
app.post(`/api/register`, (req, res) => {
    const { username, email, password } = req.body;
    // Check if body field is filled
    if (!username || !email || !password) {
        return res
            .status(401)
            .json({ err: "Fill in the Username, Email, and Password Field!" });
    }

    const user = users.find((user) => user.email === email);

    // Check if user already exists
    if (user) {
        return res.status(401).json({ err: "User Already Exists!" });
    }
    // Push new user to Users Array
    users.push({ username, email, password });

    return res.status(201).json({ username, email, password });
});

// Login User
app.post(`/api/login`, (req, res) => {
    const { email, password } = req.body;

    if (!email || !password) {
        return res
            .status(401)
            .json({ err: "Fill in the Username, Email, and Password Field!" });
    }

    const user = users.find((user) => user.email === email);

    if (!user) {
        return res.status(401).json({ err: "User Not Found!" });
    }

    return res.status(200).json(user);
});

The code snippet includes API routes and an array for storing users without using Passport.js.

When we test the Register and Login API request, we get the following response:

Image Illustrating the tes of the Register and Login API routes

Note: For this article, I am using ThunderClient, but you can use Postman or any other API client you prefer.

You then add sessions to the Login user API route by assigning the new user details to the req.session.user object. This creates a new session object with those details.

  req.session.user = { username, email, password };
app.post(`/api/login`, (req, res) => {
    const { email, password } = req.body;

    if (!email || !password) {
        return res
            .status(401)
            .json({ err: "Fill in the Username, Email, and Password Field!" });
    }

    const user = users.find((user) => user.email === email);

    if (!user) {
        return res.status(401).json({ err: "User Not Found!" });
    }
    // Session
    req.session.user = { username, email, password };

    return res.status(200).json(user);
});

Once the user is stored in the session, their information can be accessed anywhere in the project using the req.session.user object. The response appears the same when you register again:

Image illustraing the user being stored in session

But the session details are visible in the terminal, and the session ID is similar to the cookie shown in the cookie tab in Thunder Client:

Image illustrating session details on the terminal

Using Middlewares for our API Routes

Our next API route needs to verify if a user is authenticated before returning the user’s details. To do this, we will use a middleware. First, create a new folder called middleware/ and add a new file named verifyUser Inside it.

In the verifyUser file, check whether a user is authenticated. If the user object exists in the req.session method, the API route will return the user’s details. Then, if the user object does not exist in the req.session method, the API route will return a JSON string of "Unauthorized!":

export const verifyUser = (req, res, next) => {
    if (!req.session.user) {
        return res.status(409).json({ err: "UnAuthorized!" });
    }

    next();
};

After creating the file and adding the code snippet, import the verifyUser middleware into your index.js file:

import { verifyUser } from "./middleware/verify.js";

Then, set up a new API route in your index.js file using a GET request to retrieve the user's details from the user object in the req.session method with the following code:

app.get(`/api/me`, verifyUser, (req, res) => {
  return res.status(200).json(req.session.user);
});

Now, if you try to run the GET user API endpoint without creating a user or logging in, you get an “UnAuthorized“ JSON response:

Image illustrating the Get user API request returning "Unauthorized"

But if the user is logged in, we get a response with the user's details:

Image illustrating the user details be returned from the Get user API request

Logging Out the User and Removing the Session

Logging out a user with a session involves destroying the session and clearing the cookies. To log out a user with the session, create a new API route in the index.js file.

The Logout route will be a GET request that deletes the session and clears the cookie from memory:

app.get(`/api/logout`, (req, res) => {
    req.session.destroy();
    res.clearCookie("connect.sid");

    return res.sendStatus(200);
});

We use the following methods to ensure the user is successfully logged out:

  • req.session.destroy(): This method invalidates and deactivates a session.

  • res.clearCookie(‘cookie_name.sid‘): This method removes the session stored as a cookie, and it takes a string argument, which is the name of the cookie you want to remove.

GIF showing the user successfully logging out

We have explored how to simulate authentication using sessions and cookies, but this method is not ideal when adding authentication to an Express backend server.

Stopping and restarting your backend server clears the session and cookie, requiring you to create a new user to store it in the session again.

The server persists your session data in an Express web app using the connect-mongo package and MongoStore configuration options gotten from the package, working hand in hand with express-session. However, we will add this to the project after we cover the basics of Passport.js.

Benefits of Using Session-Based Authentication

Session-based authentication simplifies the authentication process for developers. Here are some advantages of using session-based authentication in your project:

Storing User and Data: It helps maintain user states and store data throughout the application across multiple requests, providing a personalised experience.

Easy to Understand: The simple implementation of sessions makes them more beginner-friendly and easier to maintain.

Secure: It is safe and transparent for the user because the session user object is stored on the server.

Drawbacks Of Using Session-Based Authentication

Session-based authentication stands out because of its features and how simple it makes applying authentication. However, these are some areas where it falls short:

Scalability: In large-scale applications, storing and managing a large number of sessions can affect performance and user experience.

Security Concerns: When sessions aren't implemented correctly, they can be susceptible to attacks.

Data Loss: If the server hosting the session encounters an error or restarts, the session data stored on the server might be lost.

What is Passport.js

Before diving into Passport.js, we had to first understand basic sessions, as this forms the foundational knowledge required for Passport.js.We then explored what sessions are, why we should use them, how they work, and how to implement them in an Express web app.

Passport.js, on the other hand, is an authentication middleware in Node.js. It is modular and flexible, making it easy to integrate with any Express-based web application. It includes various strategies for authentication, such as using a username and password (local), Google, GitHub, and more.

How Passport.js Works

Passport.js takes a different approach to authenticating users while still using sessions and cookies behind the scenes.

Here is how the Passport authentication process works:

  1. The user submits a POST request with details like username, email, and a hashed password.

  2. When the user tries to access their details, it calls the passport.authenticate("local") middleware, which is set up for the Passport local strategy in this tutorial.

  3. The Passport local strategy includes a second argument that takes the username or email and password.

  4. The user details from the username or email and password are used to find the created user and check if the hashed password matches the stored password.

  5. If the user is found and the password matches, the verify callback function returns done(null, user). If not, it returns an error with done(true, null). The done function then allows the code to proceed to the serialise user method.

  6. The serialise user method accesses the user object passed from the previous done function. This stored the user ID in the session

  7. passport.session() is called, and the passport.authenticate method runs on every request. If the serialised user object is found in the session, the user is considered authenticated.

  8. The deserialise user method will then get the full user details from the database based on the ID returned from the serialise user method, and the result from the deserialise user method is attached to the request as req.user

  9. Once the req.user method is retrieved, you can then get your user details by adding the passport.authenticate() middleware to your GET request ApI route and check if the user is authenticated by either using the req.user.isAuthenticated() method or req.user.isUnauthenticated() method provided by Passport.js

  10. To log out a user after they have been authenticated, Passport provides a req.logout method. This method removes the user ID stored in the session and clears the session data linked to the currently logged-in user from the cookie and server. After that, you can redirect the user back to the login route.

If you want to view the Passport.js Implementation source code in this article, check out this GitHub link

Implementing Passport.js

Now that we've reviewed how sessions and Passport works, let's move on to implementing Passport in our project.

Set up a new project folder like we did earlier when we worked on sessions, and install the necessary dependencies:

pnpm add express express-session passport mongoose

Now, add the following code snippet to your new index.js file. We will reuse the code from the earlier express-session implementation and add Mongoose to connect to a MongoDB database with the snippet below:

import express from "express";
import session from "express-session";
import mongoose from "mongoose";
import passport from "passport";

mongoose.connect("Mongo_Url");
const app = express();

app.use(
    session({
        secret: "S3CRET_KEY",
        resave: false,
        saveUninitialized: false,
        cookie: {
            maxAge: 60000 * 60,
        },
    })
);

app.use(express.json());
app.use(express.urlencoded({ extended: false }));
app.use(passport.initialize());
app.use(passport.session());

app.listen(4000, () => console.log("Server Running on  PORT: 4000"));

The code snippet looks similar to what we used when implementing sessions before. However, now we call and import the passport.initialize and passport.session methods into our index.js file.

Once the Passport middleware is set up, we can use different Passport.js strategies. To keep things simple in this tutorial, we will only use the passport-local strategy. This strategy allows us to log in users and save them to the session using their username or email, and password.

Creating The User Model and Route

Now that the Passport middleware and session are set up, create a models/ folder with a User.js file. Our User.js file in the models folder will contain the user schema, which includes fields for username, email, and password:

import mongoose from "mongoose";

const { Schema, model } = mongoose;
// User Schema
const userSchema = new Schema({
    username: {
        type: String,
        required: true,
    },
    email: {
        type: String,
        required: true,
        lowercase: true,
        unique: true,
    },
    password: {
        type: String,
        required: true,
        minLength: 8,
    },
});

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

export default User;

Note: This article assumes you have already created a MongoDB database and added it to MongoDB Compass.

Next, create a register user API route in our index.js file after the passport.session() method with the code below:

app.post(`/api/register`, async (req, res) => {
    const { username, email, password } = req.body;

    if (!username || !email || !password) {
        return res
            .status(401)
            .json({ err: "Fill in the Username, Email, and Password Field!" });
    }

    const newUser = await User.create({
        username,
        email,
        password,
    });

    return res.status(201).json({
        id: newUser._id,
        username: newUser.username,
        email: newUser.email,
    });
});

The code snippet is a POST request that takes username, email, and password as body data, encrypts the password, and then adds it to the MongoDB database.

This part of the code is used to extract any JSON object or array stored in the request body.

  const { username, email, password } = req.body;

If you haven't used the create method for a Mongoose model, it creates new documents in a MongoDB collection (User).

Note: Don’t forget to import the User model file in the index.js file

  const newUser = await User.create({
    username,
    email,
    password
  });

Once a user is created, we then get our response, which is a JSON object containing the details of the newly created user.

Finally, test out the signup API request:

GIF showing the test of the signup API request

If you get a response like this, it means the user has been successfully created and will be visible in your MongoDB Compass:

Image illustrating the newly created user in the database

This means our code works, but it's a bad idea to store sensitive details like passwords in the database as plain text. It needs to be encrypted. To do this, we will use an npm package for password hashing.

Password Hashing with Bcrypt.js

To install the Bcrypt.js npm package, run the following command:

pnpm add bcryptjs

Then, import the Bcrypt.js package into our index.js file with the following code:

 const hashPassword = await bcrypt.hash(password, 10);

We use the hash method in Bcrypt.js to encrypt plain-text passwords. This hash method takes two arguments: The plain text password from req.body method, and the specified number of salt rounds.

Salt rounds add a random string of characters to the password before hashing. After salting, the password is hashed multiple times. You can adjust the number of rounds, which is usually set between 10 and 12. More rounds make the hashing more secure, but also slower.

To hash our password in the Register user Api route, implement the hash password, then set the password property in the Mongoose create method to the hashed password instead of the plain password:

const hashPassword = await bcrypt.hash(password, 10);
// Create New User
const newUser = await User.create({
    username,
    email,
    password: hashPassword
});

Now that the password is hashed, when you make a register request again, the new user is created with an encrypted password:

GIF showing the newly created user with the hashed and encrypted password

Image illustrating that the user password is now hashed in the database

Note: If you notice in your Thunder client response, we get only the ID, username and email. I intentionally did this as sensitive or unnecessary information, such as passwords, shouldn’t be returned to the user

To see if the user already exists, create a variable to find a database document using the email property. If the user is found, we should get a JSON response with an error and stop the code from creating a new user. Then, add the code snippet below to your code before hashing the password to check if the user already exists before creating a new user.

     const user = await User.findOne({
       email
   });
   if (user) {
       return res.status(401).json({
           err: "User Already Exists!"
       });
   }

Implementing Passport Local Strategy

Passport.js offers various strategies to help authenticate your Express.js web application. In this article, we will focus on the passport-local strategy, which is a local strategy and one of the most popular. It authenticates users by using a username and password.

To install passport-local in our project, run the following command:

pnpm add passport-local

Now, create a new file with your preferred name, and import passport from the passport package along with the Strategy class from the passport-local package:

import passport from "passport";
import { Strategy } from "passport-local";

Then, use the passport.use method to apply the new instance of the Strategy class:

passport.use(new Strategy({}, () => {}));

The Strategy class includes an options object and a verify callback:

The options object is optional and customises how the strategy works. It includes the keys usernameField, passwordField, and passReqToCallback

We will not explore the passReqToCallback property in this article because it is rarely used in real-world projects.

  • The usernameField key determines what will be used as the username parameter in the verify callback function. By default, it is set to username, but it is often changed to email.
passport.use(new Strategy({usernameField:"email"}, async() => {}));
  • The passwordField works similarly to usernameField but is used for the password parameter in the verify callback function.
passport.use(new Strategy({passwordField:"current_password"}, async() => {}));
  • The verify callback is a required parameter. It is a function used to define how the user will be authenticated.

It includes a username, password, and a done parameter:

  • The username parameter is the value from the field you defined as usernameField.

      passport.use(new Strategy({}, async(username) => {}
    
  • The password parameter is the value from the field you defined as passwordField.

passport.use(new Strategy({}, async(username, password) => {}
  • The done parameter is a callback function that you call to complete authentication. It includes three arguments: error, user, and info.

    • error: If something goes wrong during authentication, this is set to true. If no error occurs, the error argument is set to null.

        passport.use(
            new Strategy(
                { usernameField: "email" },
                async (username, password, done) => {
                    if (!user) {
                        done(true);
                    }
                }
            )
        );
      
    • user: This is the user object obtained from the authenticated user, which comes from the user found in the database. If authentication fails, the user parameter is set to null or false.

        passport.use(
            new Strategy(
                { usernameField: "email" },
                async (username, password, done) => {
                    done(true, null);
                }
            )
        );
      
    • info: This third argument is used to display messages, typically success or error messages.

        passport.use(
            new Strategy(
                { usernameField: "email" },
                async (username, password, done) => {
                    if (!user) {
                        done(true, null, { err: "Error Occurred!" });
                    }
                }
            )
        );
      

Adding authentication logic to the verify callback function

When adding authentication logic to the verify callback function, you need to check if the user exists and use bcrypt.js to compare the plain password with the hashed password before using the done method to return an error or the user if the user exists. Paste the following code to add the authentication logic:

import passport from "passport";
import { Strategy } from "passport-local";
import User from "./models/User.js";

passport.use(
    new Strategy(
        { usernameField: "email" },
        async (username, password, done) => {
            try {
                const user = await User.findOne({ email });

                // Check If User Exists and Password Matches
                if (!user && (await bcrypt.compare(password, user.password))) {
                    done(true);
                }

                return done(null, user);
            } catch (error) {
                return done(error);
            }
        }
    )
);

Note: Don’t forget to import the User model file in the local strategy file.

Serialize User Method

The serialize user method in Passport.js is used to decide what user data is stored in session. It stores the user data through the user ID, which can then be later retrieved and used to find the user from the database.

We serialize the user using the serializeUser() method provided by the passport local strategy. This method takes a user parameter and a callback function. We then get an error message if the user isn't found, or get the user ID if the user exists. The user ID is then extracted from the user parameter and saved.

Add the code snippet after the local strategy instance:


passport.serializeUser((user, done) => {
  done(null, user._id);
});

The user ID is saved to the session store from the serialise user method:

Image illustrating the user ID being stored in session by Passport.js

Deserialize User Method

The deserialize user method is used to retrieve the full user object from the stored session data. This is typically an ID, and it is the ID we stored in session with the serialize user method, now you see how they work hand in hand.

Once the user is serialized and the user ID is obtained, the deserialize user method retrieves the full user details from the database using the ID, and attaches the result as req.user With the done function.

Paste in the code snippet after the serialize user method:

passport.deserializeUser(async (userId, done) => {
    try {
        if (!userId) {
            return done(null, false, { err: "Invalid User Id!" });
        }
        // console.log(`Deserialize User!`);
        const user = await User.findById(userId);
        if (!user) {
            return done(null, false, { err: "Invalid User!" });
        }
        done(null, user);
    } catch (error) {
        done(error);
    }
});

Logging In Users With The passport.authenticate() middleware

We would need to log in and out of our project. To do this, create a new Login API route in the index.js file after importing the local strategy file. Use this route to access existing users and add the passport.authenticate() middleware to the API route.

app.post(`/api/login`, passport.authenticate("local"), (req, res) => {
    return res.json({
        id: req.user._id,
        username: req.user.username,
        email: req.user.email,
    });
});

When the login API route is called, the passport.authenticate() middleware uses our implementation of the local strategy. As explained earlier, it processes the user details, then serializes and deserializes the user, attaching the details to the req.user object.

Persisting Sessions with Mongo Store

By default, sessions are stored in memory. This means that if you stop the server, you'll need to save the session details again, no matter how long the cookie is set to last.

Mongo Store is gotten from the connect-mongo npm package, it solves this problem by working together with express-session and the mongoose npm package, so install the connect-mongo package with the command below:

pnpm add connect-mongo

Then, modify your session method configuration by importing Mongo Store from the connect-mongo package and add it to the session store configuration option with the code snippet below:

import MongoStore from "connect-mongo";
app.use(
    session({
        secret: "S3CRET_KEY",
        resave: false,
        saveUninitialized: false,
        cookie: {
            maxAge: 60000 * 60,
        },
        store: MongoStore.create({
            mongoUrl: "MongoUrl",
        }),
    })
);

The connect-mongo package uses Mongo Store to create a sessions collection in your MongoDB database. This stores sessions in the database instead of memory, keeping the current session accessible until the user logs out or the session is manually deleted.

Image illustrating that the session is stored in the database with the connect-mongo npm package

Retrieving The User with Passport req.user method

To retrieve the user details after creating a new user or when currently logged in, create a GET request that returns the req.user object. This request should also check if the user exists with the verify user middleware we implemented earlier when we looked at sessions.

Add the Get user API route to our index.js file with the code below:

app.get(`/api/me`, verifyUser, (req, res) => {
    return res.status(200).json({
        id: req.user._id,
        username: req.user.username,
        email: req.user.email,
    });
});

Verify User Middleware

This verify user middleware is similar to what we did before, but in this case, it uses the req.user.isUnauthenticated() method provided by Passport.js.

Now, create a middleware folder with a verifyUser.js file. After adding the following code, remember to import the middleware:

export const verifyUser = (req, res, next) => {
    if (req.isUnauthenticated()) {
        return res.status(409).json({ err: "UnAuthorized!" });
    }
    next();
};

Logging out the user with Passport's req.logout method

To log out of our project in Passport, we make use of the req.logout method. This removes the ID stored in the session and clears the session data linked to the currently logged-in user from the cookie and server.

Set up a new GET request API route and add the following code snippet to the index.js file:

app.get(`/api/logout`, async (req, res) => {
    req.logOut((err) => {
        if (err) {
            return res.status(500).json({ err: "Error Found!" });
        }
    });
    res.sendStatus(200);
});

Once the user is successfully logged out, Thunder Client will return an “OK" Response and clear the session from the cookie and the MongoDB database:

Image illustrating "OK" response returned from Thunder Client

Image illustrating that the session has beeen cleared and deleted from the database(server))

If you then try to get the user details without being logged in, it will return “UnAuthorized!":

Image illustrating that when you try to login without the correct details or without being logged in, Thunder Client returns an "Unauthorized!" response.

Testing the Passport.js project

With all the code and concepts we have implemented, you can see how session-based authentication with Passport works and how to set it up. Now, test the completed project to see how everything functions together:

GIF showing all the functionality and features of the API we created while learning about session-based authentication

Conclusion

Session-based authentication is an authentication method in web development that uses sessions and cookies to store user data. In this article, we learned what a session is, how it works, along with its benefits and drawbacks, and how to implement sessions.

We mainly explored Passport.js, a middleware for Express-based web applications that offers many authentication strategies and supports sessions. While learning about Passport, we looked at how it works, its benefits and drawbacks, and how to implement it in a project.

This was a lengthy deep dive because we needed to understand sessions before exploring Passport.js. If you made it to the end, please like and comment.

Thank You!

Next Step and Resources

If you want to learn more about Session-Based Authentication or Passport.js, you can practice with different Passport.js strategies or authentication methods using the following resources: