Home / Articles

Role-Based Access Control in Express with JWT and Two Middlewares

Learn how to enforce role-based access in an Express API by pairing JWT authentication middleware with a reusable authorize() guard, and when to answer 401 versus 403.

Publisher: REACTAPP.TOP4 min read1024 words

This article is published in English.

Sources: What is RBAC in Node.js?

In most backends, different users are allowed to do different things: an admin can delete accounts, a manager can edit them, a regular user can only read. Scattering if (user.role === ...) checks through controllers quickly becomes unmaintainable and easy to get wrong. This guide shows how to express those rules as role-based access control (RBAC) in Express with two small middlewares, and how to pick the right status code when a request is refused.

A typical permission matrix looks like this:

Admin   → Create, Read, Update, Delete
Manager → Read, Update
User    → Read

How RBAC fits into the request pipeline

RBAC is an authorization model: access to an endpoint is granted or denied based on the role attached to the caller. In a JWT-based Node.js API, the pieces line up in a fixed order:

Login
  ↓
JWT Token
  ↓
Authentication Middleware
  ↓
Check User Role
  ↓
Authorization Middleware
  ↓
Controller

Two separate questions are answered along the way. Authentication establishes who the caller is. Authorization decides what that caller may do. Keeping them in separate middlewares makes each one simple and testable. The distinction is covered in more depth in where authentication and authorization belong.

Mapping roles to endpoints

Consider a standard set of user-management routes:

GET    /api/users
POST   /api/users
PUT    /api/users/:id
DELETE /api/users/:id

The desired policy for them:

Admin   → All APIs
Manager → GET, PUT
User    → GET only

Rather than re-checking the role in every controller, the policy can be attached declaratively to each route.

Putting the role into the token

Each user record carries a role:

{
  name: "Ash",
  email: "ash@example.com",
  role: "admin"
}

At login, the role is signed into the JWT alongside the user ID, so later requests can be authorized without a database lookup:

const token = jwt.sign(
  {
    userId: user._id,
    role: user.role
  },
  process.env.JWT_SECRET,
  { expiresIn: "1h" }
);

The trade-off: the role is frozen in the token until it expires. If an admin is demoted, the old token still says admin for up to an hour. Short expiry times limit that window; for sensitive operations, re-reading the role from the database is safer.

Authenticating the request

The first middleware pulls the token from the Authorization: Bearer <token> header, verifies its signature and expiry, and stores the decoded payload on req.user. Missing or invalid tokens end the request with 401:

const authenticate = (req, res, next) => {
  const token = req.headers.authorization?.split(" ")[1];
if (!token) {
    return res.status(401).json({
      message: "Authentication required"
    });
  }
  try {
    const decoded = jwt.verify(
      token,
      process.env.JWT_SECRET
    );
    req.user = decoded;
    next();
  } catch (error) {
    return res.status(401).json({
      message: "Invalid or expired token"
    });
  }
};

After it runs, every downstream handler can trust req.user. When verifying, it is also good practice to pin the accepted algorithms via the algorithms option of jwt.verify instead of accepting whatever the token header claims.

Authorizing by role

The second middleware is a factory. It takes the allowed roles and returns a handler that rejects anyone whose role is not on the list with 403:

const authorize = (...roles) => {
  return (req, res, next) => {
    if (!roles.includes(req.user.role)) {
      return res.status(403).json({
        message: "Access denied"
      });
    }
next();
  };
};

Note that it reads req.user.role, so it must always run after authenticate; on its own it would throw on an undefined req.user.

Protecting a route is now a matter of composing the two:

router.delete(
  "/users/:id",
  authenticate,
  authorize("admin"),
  deleteUser
);

Only an authenticated admin reaches deleteUser. Allowing several roles just means passing more arguments:

router.put(
  "/users/:id",
  authenticate,
  authorize("admin", "manager"),
  updateUser
);

Here both admins and managers can update users.

Choosing between 401 and 403

These two codes are frequently confused, but they signal different problems.

401: the caller is not authenticated

The server does not know who is calling, typically because of one of these:

No token
Invalid token
Expired token

The client should log in again or refresh its token.

403: the caller is known but not allowed

Identity is established, yet the role does not grant access:

User role = user
Required role = admin

Retrying with the same credentials will not help. A short rule: 401 means "we don't know who you are"; 403 means "we know, and the answer is no".

Quick review

  • What is RBAC? Access control where API permissions derive from the caller's assigned role.
  • How is it built in Express? One middleware verifies the JWT, a second checks the role against the route's allowed list.
  • Where must it be enforced? On the backend. Hiding buttons in the UI is cosmetic, because anyone can call the API directly.
  • Why middleware? It keeps authorization reusable and out of business logic, so one guard protects many routes.
  • What does RBAC not cover? Ownership rules such as "users may edit only their own profile" need an extra check against the resource itself.

Wrapping up

The complete flow is short:

Client
  ↓
JWT
  ↓
Authentication
  ↓
Authorization / RBAC
  ↓
Controller
  ↓
Database

Authenticate the caller, read the role, compare it with what the route permits, then allow or reject. As an application grows, consider mapping roles to fine-grained permissions such as user:create, user:update and user:delete, and checking permissions in routes instead of role names. Adding a new role then means editing one mapping rather than every route.