Building Role-Based Access Control (RBAC) with JWT in Node.js

November 2, 2024 (2w ago)

Building Role-Based Access Control (RBAC) with JWT in Node.js

Role-Based Access Control (RBAC) is a powerful method for managing permissions based on user roles within an application. By combining RBAC with JWT (JSON Web Token) authentication, you can control access to specific resources, ensuring that only authorized users can access or modify certain parts of your application.

This guide explains how to implement RBAC in a Node.js application using Express, JWT, and Mongoose, covering everything from defining roles and permissions to securing routes with middleware.


What is Role-Based Access Control (RBAC)?

RBAC allows you to assign specific roles to users (e.g., admin, user, editor) and control which resources each role can access or modify. With RBAC, you can manage permissions centrally, making it easier to define and enforce access rules across your application.

Example RBAC System

In a typical system:

  1. Admin: Can create, read, update, and delete all resources.
  2. Editor: Can create and edit specific content but may not delete or access administrative functions.
  3. User: Has read-only access to general content.

Setting Up the Project

We’ll build upon the previous JWT setup by adding RBAC functionality. Ensure you have Express, Mongoose, jsonwebtoken, and bcryptjs installed.


Step 1: Define User Roles in the Mongoose Schema

Let’s start by adding a role field to the User schema. This field will store the role assigned to each user.

models/User.js

const mongoose = require("mongoose");
const bcrypt = require("bcryptjs");
 
const userSchema = new mongoose.Schema({
  username: { type: String, required: true, unique: true },
  email: { type: String, required: true, unique: true },
  password: { type: String, required: true },
  role: { type: String, enum: ["user", "editor", "admin"], default: "user" } // Role field
});
 
// Hash password before saving user
userSchema.pre("save", async function (next) {
  if (this.isModified("password")) {
    this.password = await bcrypt.hash(this.password, 10);
  }
  next();
});
 
// Compare password for login
userSchema.methods.comparePassword = async function (password) {
  return bcrypt.compare(password, this.password);
};
 
module.exports = mongoose.model("User", userSchema);

In this schema:


Step 2: Update JWT Generation to Include Role

When generating a JWT, include the user’s role in the token payload. This allows the server to verify the role when validating permissions.

routes/auth.js

const jwt = require("jsonwebtoken");
 
const generateAccessToken = (user) => {
  return jwt.sign(
    { id: user._id, role: user.role },
    process.env.JWT_SECRET,
    { expiresIn: "15m" }
  );
};

In this update, generateAccessToken adds the role to the token payload. The server can then check the role to control access to resources.


Step 3: Creating Middleware for Role-Based Access Control

Now, let’s create a middleware function that checks both the user’s authentication status and their role.

middleware/roleMiddleware.js

const jwt = require("jsonwebtoken");
 
const authMiddleware = (req, res, next) => {
  const token = req.headers.authorization?.split(" ")[1];
  if (!token) return res.status(401).json({ message: "Access denied. No token provided." });
 
  try {
    const decoded = jwt.verify(token, process.env.JWT_SECRET);
    req.user = decoded;
    next();
  } catch (error) {
    res.status(403).json({ message: "Invalid or expired access token" });
  }
};
 
const roleMiddleware = (roles) => (req, res, next) => {
  if (!roles.includes(req.user.role)) {
    return res.status(403).json({ message: "Access denied. Insufficient permissions." });
  }
  next();
};
 
module.exports = { authMiddleware, roleMiddleware };

Here:

Example Usage

You can combine authMiddleware and roleMiddleware to secure specific routes.

const express = require("express");
const { authMiddleware, roleMiddleware } = require("../middleware/roleMiddleware");
 
const router = express.Router();
 
router.get("/admin", authMiddleware, roleMiddleware(["admin"]), (req, res) => {
  res.send("Welcome Admin!");
});
 
router.get("/editor", authMiddleware, roleMiddleware(["admin", "editor"]), (req, res) => {
  res.send("Welcome Editor!");
});
 
module.exports = router;

In this example:


Step 4: Applying RBAC to Routes

Let’s create routes that are restricted to specific roles, such as an admin route for managing users and an editor route for managing content.

routes/admin.js

routes/admin.js

const express = require("express");
const { authMiddleware, roleMiddleware } = require("../middleware/roleMiddleware");
const User = require("../models/User");
 
const router = express.Router();
 
// Admin-only route for viewing all users
router.get("/users", authMiddleware, roleMiddleware(["admin"]), async (req, res) => {
  try {
    const users = await User.find().select("-password"); // Exclude passwords
    res.status(200).json(users);
  } catch (error) {
    res.status(500).json({ message: error.message });
  }
});
 
module.exports = router;

Here, only users with the admin role can access the /users route to retrieve a list of all users.

routes/editor.js

routes/editor.js

const express = require("express");
const { authMiddleware, roleMiddleware } = require("../middleware/roleMiddleware");
 
const router = express.Router();
 
// Editor and Admin can access this route
router.post("/content", authMiddleware, roleMiddleware(["editor", "admin"]), (req, res) => {
  res.send("Content creation allowed for Editor and Admin!");
});
 
module.exports = router;

In this route, both editor and admin roles can access the content creation route.


Step 5: Testing Role-Based Access

  1. Register users with different roles (e.g., admin, editor, user).
  2. Log In and obtain JWTs for each role.
  3. Test Protected Routes by passing the JWT in the Authorization header and ensuring only the specified roles can access each route.

Example Request

GET /admin/users
Authorization: Bearer <adminToken>

This request will only succeed if the token belongs to a user with the admin role. Trying to access this route with a non-admin token will result in a 403 status.


Best Practices for Implementing RBAC with JWT

  1. Keep Access Tokens Short-Lived: Use short expiration times for access tokens to reduce the risk if a token is compromised.
  2. Avoid Hardcoding Roles: Define roles in a centralized configuration or database instead of hardcoding them throughout the codebase.
  3. Regularly Review Role Permissions: Regularly audit roles and permissions to ensure they align with application requirements and security best practices.
  4. Handle Token Expiration Gracefully: Ensure the client can detect and handle expired tokens, prompting users to log in again.
  5. Use Secure Token Storage: Store tokens securely on the client side (e.g., in HTTP-only cookies for refresh tokens) to prevent access by malicious scripts.

Conclusion

Implementing Role-Based Access Control (RBAC) with JWT in a Node.js application allows for fine-grained control over resources, ensuring that users can only access what they’re authorized to see. By assigning roles and using middleware to verify both authentication and permissions, you can enforce access rules across your application easily and effectively.

This approach is scalable, flexible, and well-suited to applications where security and organized access management are essential. Start using these techniques in your project to enhance user authentication and secure your application’s resources.