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.
| Concern | HTTP status | Meaning |
|---|---|---|
| Missing/invalid credentials | 401 Unauthorized | ”I don’t know who you are” |
| Known user, insufficient role | 403 Forbidden | ”I know who you are, and you can’t do this” |
| Resource doesn’t exist | 404 Not Found | Sometimes 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
authenticateonce withrouter.use(authenticate)at the top of a protected router instead of repeating it on every route. Then each route only needs its specificauthorize(...)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.
| Aspect | RBAC | ABAC |
|---|---|---|
| Decision input | User’s role | Subject, resource, action, environment attributes |
| Example rule | ”Admins can delete users" | "Authors can edit posts they own, before publication” |
| Granularity | Coarse, role-level | Fine, per-resource |
| Complexity | Low, easy to audit | Higher, dynamic |
| Best for | Broad feature gating | Ownership 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) from403(identity without permission). - Centralize role and permission checks in middleware factories rather than scattering
ifstatements 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
403unless 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
roleclaim a client can edit; in Express 5, unhandled async errors reject the request, soawaityour 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.