Skip to content
Express.js ex auth 5 min read

Role-Based Authorization

Authentication answers who you are; authorization answers what you’re allowed to do. Once a request carries a verified identity — typically req.user set by a JWT or session middleware — you still need a gate that decides whether this user may reach this route. Role-Based Access Control (RBAC) is the most common model: every user holds one or more roles, and routes declare which roles they accept. This page builds a reusable authorize(...roles) middleware, models richer permissions, returns proper 403 responses, and contrasts RBAC with attribute-based access.

Authentication vs. authorization

These two steps run in sequence, and order matters. An unauthenticated request should be rejected with 401 Unauthorized before any role check runs — there is no role to inspect yet. A request that is authenticated but lacks the required role is rejected with 403 Forbidden. Conflating the two leaks information and confuses clients.

ConcernHTTP statusMeaning
Missing/invalid credentials401 Unauthorized”I don’t know who you are”
Known user, insufficient role403 Forbidden”I know who you are, and you can’t do this”
Resource doesn’t exist404 Not FoundSometimes used instead of 403 to hide existence

This page assumes an upstream middleware (see JWT Authentication) has already populated req.user with at least an id and a role.

A reusable authorize middleware

The cleanest RBAC pattern in Express is a middleware factory: a function that takes the allowed roles and returns a middleware closure. This keeps route declarations readable — authorize("admin") reads like an English sentence — and centralizes the rejection logic in one place.

// middleware/authorize.js
function authorize(...allowedRoles) {
  return (req, res, next) => {
    if (!req.user) {
      return res.status(401).json({ error: "Authentication required" });
    }

    if (!allowedRoles.includes(req.user.role)) {
      return res.status(403).json({
        error: "Forbidden",
        message: `Requires one of: ${allowedRoles.join(", ")}`,
      });
    }

    next();
  };
}

module.exports = authorize;

Because authorize is called at route-definition time, the allowedRoles array is captured once and reused for every request — there’s no per-request allocation beyond the closure itself.

Protecting routes

Chain the middleware after your authentication step. Express runs each function left to right, short-circuiting as soon as one sends a response.

const express = require("express");
const authorize = require("./middleware/authorize");
const authenticate = require("./middleware/authenticate");

const router = express.Router();

// Any logged-in user
router.get("/profile", authenticate, (req, res) => {
  res.json({ id: req.user.id, role: req.user.role });
});

// Editors and admins
router.post("/articles", authenticate, authorize("editor", "admin"), (req, res) => {
  res.status(201).json({ created: true, author: req.user.id });
});

// Admins only
router.delete("/users/:id", authenticate, authorize("admin"), (req, res) => {
  res.json({ deleted: req.params.id });
});

module.exports = router;

A request from an editor to DELETE /users/42 is stopped before the handler runs:

Output:

HTTP/1.1 403 Forbidden
Content-Type: application/json

{
  "error": "Forbidden",
  "message": "Requires one of: admin"
}

Apply authenticate once with router.use(authenticate) at the top of a protected router instead of repeating it on every route. Then each route only needs its specific authorize(...) call.

Modeling permissions instead of bare roles

Hard-coding role names into routes works until requirements change — the day a “moderator” role should also delete articles, you have to hunt down every authorize("admin"). A more durable design maps roles to permissions (verbs like article:delete), and routes check permissions rather than roles.

// config/permissions.js
const ROLE_PERMISSIONS = {
  viewer: ["article:read"],
  editor: ["article:read", "article:write"],
  admin: ["article:read", "article:write", "article:delete", "user:manage"],
};

function permissionsFor(role) {
  return ROLE_PERMISSIONS[role] ?? [];
}

module.exports = { ROLE_PERMISSIONS, permissionsFor };
// middleware/requirePermission.js
const { permissionsFor } = require("../config/permissions");

function requirePermission(permission) {
  return (req, res, next) => {
    if (!req.user) {
      return res.status(401).json({ error: "Authentication required" });
    }

    const granted = permissionsFor(req.user.role);
    if (!granted.includes(permission)) {
      return res.status(403).json({ error: "Forbidden", required: permission });
    }

    next();
  };
}

module.exports = requirePermission;
router.delete(
  "/articles/:id",
  authenticate,
  requirePermission("article:delete"),
  (req, res) => res.json({ deleted: req.params.id })
);

Now adding article:delete to a new role is a single config edit, and every protected route picks it up automatically.

RBAC vs. attribute-based access (ABAC)

RBAC decides access purely from the user’s role. But many real rules depend on context — “users may edit only their own posts,” “refunds over $500 need a manager.” These can’t be expressed as a static role match because they involve the relationship between the subject, the resource, and the environment. That’s ABAC (attribute-based access control), where decisions are computed from attributes of the request.

AspectRBACABAC
Decision inputUser’s roleSubject, resource, action, environment attributes
Example rule”Admins can delete users""Authors can edit posts they own, before publication”
GranularityCoarse, role-levelFine, per-resource
ComplexityLow, easy to auditHigher, dynamic
Best forBroad feature gatingOwnership and contextual rules

In Express, ABAC is usually a check inside the handler (or a middleware that loads the resource first), because it needs the actual record:

router.patch("/posts/:id", authenticate, async (req, res) => {
  const post = await db.posts.findById(req.params.id);
  if (!post) return res.status(404).json({ error: "Not found" });

  const isOwner = post.authorId === req.user.id;
  if (!isOwner && req.user.role !== "admin") {
    return res.status(403).json({ error: "Forbidden" });
  }

  const updated = await db.posts.update(post.id, req.body);
  res.json(updated);
});

Most production apps combine both: RBAC at the route edge for coarse gating, ABAC inside handlers for ownership and contextual rules.

Best Practices

  • Always run authentication before authorization, and distinguish 401 (no identity) from 403 (identity without permission).
  • Centralize role and permission checks in middleware factories rather than scattering if statements across handlers.
  • Prefer permission-based checks (article:delete) over hard-coded role names so policy changes stay in one config file.
  • Deny by default — return 403 unless a rule explicitly grants access, and treat an unknown role as having no permissions.
  • Use RBAC for broad feature gating and ABAC inside handlers for ownership and context-dependent rules.
  • Never trust a role claim a client can edit; in Express 5, unhandled async errors reject the request, so await your resource loads and let your error middleware return clean responses.
  • Log authorization failures with the user id and attempted action to support auditing and abuse detection.
Last updated June 14, 2026
Was this helpful?