Securing MERN Application with JWT and Passport.js

Securing MERN Application with JWT and Passport.js

We will use the passport-jwt strategy for route protection and passport-local for user authentication. Additionally, we will implement role-based acce

·

11 min read

In this tutorial, we’ll walk through securing a MERN (MongoDB, Express.js, React.js, Node.js) application using JSON Web Tokens (JWT) and Passport.js. Specifically, we will use the passport-jwt strategy for route protection and passport-local for user authentication. Additionally, we will implement role-based access control.

What is JWT?

JSON Web Tokens (JWT) are a compact, URL-safe means of representing claims to be transferred between two parties. Once a user logs in, the server generates a JWT that encodes the user’s information, and the client includes the JWT in the headers for every subsequent request.

What is Passport.js?

Passport.js is an authentication middleware for Node.js, providing a flexible way to handle user authentication in web applications, including MERN stack applications.

Getting Started

Step 1: Create a new directory for your project and initialize it:

mkdir docker-mern-app
cd docker-mern-app
npm init -y

Step 2: Install the necessary packages:

npm install express mongoose dotenv passport passport-jwt passport-local jsonwebtoken bcryptjs helmet cors express-rate-limit

Environment Variables

Secure sensitive information using environment variables. Create a .env file in your project root:

Create a .env.development file in your project root.

NODE_ENV=development
JWT_SECRET=your_jwt_secret
REFRESH_TOKEN_SECRET=your_refresh_token_secret
DB_HOST=localhost
DB_PORT=27017
DB_NAME=docker-mern-app
DB_USER=adminuser
DB_PASSWORD=P@ssw0rd
SERVER_PORT=4000

Use dotenv package to access these in your code.

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

Setting Up Passport and JWT

Step 3: Create a User model (models/user.js):

const mongoose = require("mongoose");
const userSchema = mongoose.Schema({
  email: {
    type: String,
    required: true,
  },
  password: {
    type: String,
    required: true,
  },
  name: {
    type: String,
    required: true,
  },
  emailVerified: {
    type: Boolean,
    required: false,
  },
  refreshToken: {
    type: String,
    default: null,
  },
  role: {
    type: String,
    enum: ["user", "admin"],
    default: "user",
  },
});

userSchema.pre("save", async function (next) {
  const user = this;

  // Only hash the password if it has been modified (or is new)
  if (!user.isModified("password")) return next();

  try {
    const salt = await bcrypt.genSalt(10); // You can use another salt round value
    const hash = await bcrypt.hash(user.password, salt);

    // Replace the provided password with the hash
    user.password = hash;
    next();
  } catch (err) {
    next(err);
  }
});

// Method to compare passwords
userSchema.methods.isValidPassword = async function (password) {
  try {
    const isMatch = await bcrypt.compare(password, this.password);
    return isMatch;
  } catch (err) {
    throw err;
  }
};

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

Step 4: Configure JWT strategy for Passport (config/passport.js):

const passport = require("passport");
const JwtStrategy = require("passport-jwt").Strategy;
const ExtractJwt = require("passport-jwt").ExtractJwt;
const User = require("../models/User");

const opts = {
  jwtFromRequest: ExtractJwt.fromAuthHeaderWithScheme("jwt"),
  secretOrKey: process.env.JWT_SECRET,
};

passport.use(
  new JwtStrategy(opts, async (jwt_payload, done) => {
    try {
      const user = await User.findById(jwt_payload.data.id);
      if (user) {
        return done(null, user);
      } else {
        return done(null, false);
      }
    } catch (err) {
      return done(err, false);
    }
  })
);

Step 5: Configure Local strategy to authenticate the user using email and password:

const LocalStrategy = require('passport-local').Strategy;
const User = require("../models/User");

passport.use(
  new LocalStrategy(
    { usernameField: "email" },
    async (email, password, done) => {
      try {
        const user = await User.findOne({ email });
        if (!user) {
          console.log("No user found with email:", email);
          return done(null, false, { message: "Invalid credentials." });
        }
        const isPasswordValid = await user.isValidPassword(password);
        if (!isPasswordValid) {
          console.log("Password check failed for user:", email);
          return done(null, false, { message: "Invalid credentials." });
        }

        return done(null, user);
      } catch (err) {
        console.error("Error fetching user:", err);
        return done(err);
      }
    }
  )
);

Step 6: Set up the auth services services/auth.js:

const User = require("../models/User");
const jwt = require("jsonwebtoken");
const passport = require("passport");
const bcrypt = require("bcryptjs");

const auth = {
  signup: async function (req, res) {
    let newUser = new User({
      email: req.body.email,
      password: req.body.password,
      name: req.body.name,
      role: req.body.role
    });

    try {
      const existingUser = await User.findOne({ email: newUser.email });

      if (existingUser) {
        return res.status(422).json({
          success: false,
          msg: "Email has already been registered with us.",
        });
      }

      await newUser.save();
      return res.status(200).json({
        success: true,
        msg: "User registered successfully.",
      });
    } catch (err) {
      errorLogger.error(err);
      return res.status(422).json({
        success: false,
        msg: "Something went wrong.",
      });
    }
  },

  login: function (req, res, next) {
    passport.authenticate("local", { session: false }, (err, user, info) => {
      if (err || !user)
        return res.status(422).json({
          success: false,
          msg: info?.message || "Authentication failed",
        });

      req.login(user, { session: false }, (err) => {
        if (err) {
          console.error(err);
          return res
            .status(422)
            .json({ success: false, msg: "Something went wrong." });
        }

        const token = jwt.sign(
          {
            data: {
              id: user._id,
              email: user.email,
              name: user.name,
              role: user.role,
            },
          },
          process.env.JWT_SECRET,
          {
            expiresIn: "1h", // Set token expiration time as needed
          }
        );

        return res.status(200).json({
          msg: "Logged in Successfully.",
          success: true,
          token: "JWT " + token,
          user: {
            id: user._id,
            email: user.email,
            name: user.name,
            role: user.role,
          },
        });
      });
    })(req, res, next);
  },

  addUser: function (newUser, callback) {
    bcrypt.genSalt(10, (err, salt) => {
      bcrypt.hash(newUser.password, salt, (err, hash) => {
        if (err) throw err;
        newUser.password = hash;
        newUser.save(callback);
      });
    });
  },
};

module.exports = auth;

Implementing refresh tokens

JWTs are stateless and often have a short life. When they expire, users need to log in again to obtain a new token. To enhance user experience, we can use refresh tokens.

When a user successfully logs in, the server issues both an access token (with a shorter lifespan, e.g., 1 hour) and a refresh token (with a longer lifespan, e.g., 7 days or more).

Once the access token expires, any further requests with the expired token are rejected. Instead of logging in again, the client sends the refresh token to a specific endpoint (e.g., /refresh-token) on the server to get a new access token.

Once a new access token is issued using a refresh token, the used refresh token can be invalidated to prevent reuse. When the user logs out or when the refresh token expires, the client will need the user to log in again to obtain new tokens.

Here's how to modify the login function to include a refresh token:

login: function (req, res, next) {
    ...

        const refreshToken = jwt.sign(
          { data: user },
          process.env.REFRESH_TOKEN_SECRET,
          {
            expiresIn: "7d", // Refresh token typically lasts longer
          }
        );

        user.refreshToken = refreshToken;
        user.save();

        return res.status(200).json({
          msg: "Logged in Successfully.",
          success: true,
          token: "JWT " + token,
          refreshToken: refreshToken,
          user: {
            id: user._id,
            email: user.email,
            name: user.name,
            role: user.role,
          },
        });
      });
    })(req, res, next);
  },

Add a new function to handle token refresh:

refreshToken: async function (req, res) {
    const refreshToken = req.body.token;

    const user = await User.findOne({ refreshToken: refreshToken });
    if (!user) {
      return res
        .status(403)
        .json({ error: "Invalid or expired refresh token" });
    }

    jwt.verify(
      refreshToken,
      process.env.REFRESH_TOKEN_SECRET,
      (err, userData) => {
        if (err)
          return res.status(403).json({ error: "Invalid refresh token" });

        const newToken = jwt.sign({ data: userData }, process.env.JWT_SECRET, {
          expiresIn: "1h",
        });

        // Invalidate the used refresh token and issue a new one
        user.refreshToken = jwt.sign(
          { data: userData },
          process.env.REFRESH_TOKEN_SECRET,
          {
            expiresIn: "7d", // Example expiration
          }
        );
        user.save();

        return res.status(200).json({
          token: "JWT " + newToken,
          refreshToken: user.refreshToken,
        });
      }
    );
  },

Note: In this tutorial, we stored the refresh token in the user schema for simplicity purposes. However, for scenarios where users might log in from multiple devices or platforms (e.g., web, mobile), it’s beneficial to have a separate schema or table to manage multiple refresh tokens per user. Furthermore, with many refresh tokens and constant updates, the User table can get heavy with writes, especially if it’s frequently accessed for other operations.

Implementing tokens revocation

Having the ability to revoke refresh tokens is essential for added security, especially in scenarios like user logout, suspected malicious activity, or when a user changes their password. Once revoked, the refresh token cannot be used to obtain new access tokens.

Create a logout function:

logout: async function (req, res) {
    console.error(req.body);
    const refreshToken = req.body.token;
    const userId = req.body.userId;

    if (!refreshToken) {
      return res.status(400).json({ error: "No token provided" });
    }

    try {
      const updatedUser = await User.updateOne(
        { refreshToken: refreshToken },
        { $set: { refreshToken: null } }
      );

      if (updatedUser.nModified === 0) {
        return res
          .status(404)
          .json({ error: "User not found or already logged out" });
      }

      return res.status(200).json({ message: "Successfully logged out" });
    } catch (err) {
      return res.status(500).json({ error: "Failed to revoke token" });
    }
  },

Setting Up Role-Based Access Control

Role-based access control is essential in ensuring users can only access routes they have permission for. Our User model has a role field with two potential values: ‘user’ and ‘admin’. By inspecting this role field, you can determine the user’s permissions.

Step 7: Create the middleware middleware/checkRole.js

function checkRole(role) {
    return function(req, res, next) {
        const userRole = req.user.role;
        if (userRole === role) {
            return next();
        } else {
            return res.status(403).json({ message: 'Access forbidden: You do not have the required role' });
        }
    }
}

module.exports = checkRole;

Implementing Rate Limiting

We incorporated a rate limiter middleware on the login API to deter users from incessantly attempting to brute-force the login function.

Step 8: Create the middleware middlewares/rateLimit.js

const rateLimit = require("express-rate-limit");

const limiter = rateLimit({
  windowMs: 5 * 60 * 1000, // 5 minutes
  max: 8, // limit each IP to 8 requests per windowMs
  message: "Too many requests from this IP, please try again later.",
});

module.exports = limiter;

JWT Header Verification

Before accessing any protected route, ensure that the request contains a valid JWT in the header. Then, we use passport.authenticate to validate the token and extract the user information.

Step 9: Create the middleware middlewares/authenticated.js

const passport = require("passport");

module.exports = (req, res, next) => {
  const authHeader = req.headers["authorization"];
  const token = authHeader?.split(" ")[1];

  if (token == null) return res.status(401).json({ error: "No JWT in header" });

  passport.authenticate("jwt", { session: false }, (error, user, info) => {
    if (error) {
      console.error("Error verifying jwt:", err);
      return res.status(500).json({ error: "Server error" });
    }
    if (!user) {
      return res.status(401).json({ error: "Invalid JWT" });
    }

    // Attach the user object to the request, so you can access it in the next middleware or route handler
    req.user = user;

    next();
  })(req, res, next);
};

Step 10: Implementing the routes routes/api.js

const express = require("express");
const router = express.Router();
const auth = require("../services/auth");
const checkRole = require("../middlewares/checkRole");
const rateLimit = require("../middlewares/rateLimit");
const authenticated = require("../middlewares/authenticated");

router.post("/signup", auth.signup);
router.post("/login", rateLimit, auth.login);
router.post("/refresh-token", auth.refreshToken);
router.post("/logout", authenticated, auth.logout);
router.get("/profile", authenticated, (req, res) => {
  res.send(req.user);
});

router.get("/admin", authenticated, checkRole("admin"), (req, res) => {
  res.json({ message: "Welcome, Admin!" });
});
module.exports = router;

In the profile API, the authenticated middleware ensures that the user is authenticated and appends the authenticated user to the req object.

In the admin API, if authentication succeeds, the checkRole('admin') middleware checks whether the authenticated user has the admin role.

If both conditions are satisfied, the callback function for the route is executed, and the message “Welcome, Admin!” is returned. If the user does not have the required role, a 403 Forbidden response is sent.

Wrapping Up & Running The Server

const path = require("path");
require("dotenv").config();

const express = require("express");
const helmet = require("helmet");
const cors = require("cors");
const passport = require("passport");
const authRoutes = require("./routes/user");
const { errorLogger } = require("./helpers/logger");
const app = express();
const PORT = process.env.SERVER_PORT || 8000;

app.use(helmet());
app.use(cors());
app.use(express.json());
// disable 'X-Powered-By' header in response
app.disable("x-powered-by");

// db connection
require("./config/db-connection");

app.use(passport.initialize());
require("./config/passport");

app.use("/api/", authRoutes);

app.use((req, res, next) => {
  const err = new Error("Not Found");
  err.status = 404;
  //logger.error("Error connecting from " + req.ip, "Service Not Found 404");
  res.status(404).send("Service Not Found 404");
});

// General error-handling middleware
app.use((err, req, res, next) => {
  // Use error status if set, otherwise default to 500
  res.status(err.status || 500);

  // Display error message. In production, you might not want to send the error stack for security reasons.
  res.json({
    error: {
      message: err.message,
      stack: process.env.NODE_ENV === "production" ? null : err.stack,
    },
  });
});

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

With everything set up, you can run your server using:

NODE_ENV=development node server.js

Testing Setup:

First, install the necessary testing libraries:

npm install mocha chai chai-http --save-dev

Create test cases for JWT Generation & Validation, Role-based access, Rate limiting behaviour, and Authentication validation.

Create a test folder in the root of your project.

Test Cases:

1. JWT Generation & Validation:

const chai = require("chai");
const chaiHttp = require("chai-http");
const server = require("../server");
const should = chai.should();
const { expect } = chai;
chai.use(chaiHttp);

let jwtToken;
let adminJwtToken;

describe("JWT", () => {
  it("should generate a valid JWT token upon successful login", (done) => {
    chai
      .request(server)
      .post("/api/login")
      .send({ email: "hello@hotmail.com", password: "12345678" })
      .end((err, res) => {
        res.should.have.status(200);
        res.body.should.have.property("token");
        jwtToken = res.body.token;
        done();
      });
  });

  it("should validate a JWT token and provide access", (done) => {
    chai
      .request(server)
      .get("/api/profile")
      .set("Authorization", jwtToken)
      .end((err, res) => {
        res.should.have.status(200);
        done();
      });
  });
});

2. Role-based access:

describe("Role Access", () => {
  it("admin login successfully.", (done) => {
    chai
      .request(server)
      .post("/api/login")
      .send({ email: "admin@hotmail.com", password: "87654321" })
      .end((err, res) => {
        res.should.have.status(200);
        res.body.should.have.property("token");
        adminJwtToken = res.body.token;
        done();
      });
  });

  it("should allow admin users access to admin route", (done) => {
    chai
      .request(server)
      .get("/api/admin")
      .set("Authorization", adminJwtToken)
      .end((err, res) => {
        res.should.have.status(200);
        done();
      });
  });

  it("should prevent non-admin users from accessing the admin route", (done) => {
    chai
      .request(server)
      .get("/api/admin")
      .set("Authorization", jwtToken)
      .end((err, res) => {
        res.should.have.status(403);
        done();
      });
  });
});

3. Rate limiting behaviour:

describe("Rate Limiting", () => {
  it("should limit login attempts after 8 tries", async () => {
    for (let i = 0; i < 9; i++) {
      const res = await chai
        .request(server)
        .post("/api/login")
        .send({ email: "test@test.com", password: "wrongpassword" });

      if (i <= 7) {
        expect(res).to.not.have.status(429);
      } else {
        expect(res).to.have.status(429);
      }
    }
  });
});

4. Authentication validation:

describe("Authentication", () => {
  it("should successfully authenticate with valid credentials", (done) => {
    chai
      .request(server)
      .post("/api/login")
      .send({ email: "hello@hotmail.com", password: "12345678" })
      .end((err, res) => {
        res.should.have.status(200);
        done();
      });
  });

  it("should fail authentication with invalid credentials", (done) => {
    chai
      .request(server)
      .post("/api/login")
      .send({ email: "test@test.com", password: "wrongpassword" })
      .end((err, res) => {
        res.should.have.status(422);
        done();
      });
  });
});

Running Tests:

In your package.json under scripts, add:

"test": "NODE_ENV=development mocha ./test/*.test.js"

Now, you can run tests using:

npm test

Conclusion & Next Steps

Congratulations! You’ve secured your MERN application using JWT and Passport.js. You’ve implemented user authentication, role-based access control, refresh tokens, and more.

For the following steps, you can:

  • Set up password reset and update password functionality.

  • Build frontend components to interface with these API endpoints.

Happy coding! 🎉

Leave a comment if you need the full functional source code.

Did you find this article valuable?

Support Wynn’s blog by becoming a sponsor. Any amount is appreciated!