Authorization: Roles and Permission Middleware

Advanced
12 min

Authorization: Roles and Permission Middleware

Authentication answers "who is this?"; authorization answers "what may they do?". A logged-in user must not delete another user's data or reach admin endpoints simply because they hold a valid session or token. After this lesson you will be able to express access rules as reusable middleware, check roles and resource ownership, model finer-grained permissions, and return the right status code when a check fails.

401 versus 403

| Situation | Status | Message | | --- | --- | --- | | No credentials or invalid token | 401 Unauthorized | "Authentication required" | | Valid user, action not allowed | 403 Forbidden | "Insufficient permissions" | | Resource exists but must stay hidden | 404 Not Found | Same body as a real 404 |

Returning 404 instead of 403 for private resources avoids confirming that something exists. Use it when the URL itself reveals information, such as /users/42/invoices.

Role checks as middleware

Authorization middleware runs after authentication has placed req.user on the request. Keep it declarative so a route's requirements are visible in the router:

javascript
// src/middleware/authorize.js export const requireRole = (...roles) => (req, res, next) => { if (!req.user) return res.status(401).json({ error: "Authentication required" }); if (!roles.includes(req.user.role)) { return res.status(403).json({ error: "Insufficient permissions" }); } next(); };
javascript
// src/routes/admin.routes.js router.use(requireAuth, requireRole("admin")); // everything below is admin-only router.get("/users", adminController.listUsers); router.patch("/users/:id/role", adminController.changeRole); // src/routes/todo.routes.js router.post("/", requireAuth, requireRole("admin", "editor"), todoController.create);

Calling router.use() with the guards protects every route declared afterwards in that router, which is the usual pattern for admin areas.

Ownership checks

Roles are too coarse for "users may edit their own todos". Ownership needs the resource, so load it once in middleware and hand it to the controller through req:

javascript
// src/middleware/load-todo.js import * as todoService from "../services/todo.service.js"; export const loadTodo = async (req, res, next) => { const todo = await todoService.getOrFail(req.params.id); // throws 404 const isOwner = todo.owner.equals(req.user.id); if (!isOwner && req.user.role !== "admin") { return res.status(404).json({ error: "Todo not found" }); // do not reveal it exists } req.todo = todo; next(); }; router.patch("/:id", requireAuth, loadTodo, todoController.update); router.delete("/:id", requireAuth, loadTodo, todoController.remove);

The controller then works with req.todo and never repeats the lookup. For list endpoints, the equivalent rule is a query constraint: the service always adds { owner: req.user.id } unless the caller is an admin. Route guards protect single-resource routes; scoped queries protect collections. You need both.

Permission-based access control

When the same role needs different rights in different features, map roles to permissions instead of hard-coding role names in routes:

javascript
// src/config/permissions.js const PERMISSIONS = { viewer: ["todo:read"], editor: ["todo:read", "todo:write"], admin: ["todo:read", "todo:write", "todo:delete", "user:manage"], }; export const can = (permission) => (req, res, next) => { const granted = PERMISSIONS[req.user?.role] ?? []; if (!req.user) return res.status(401).json({ error: "Authentication required" }); if (!granted.includes(permission)) return res.status(403).json({ error: "Insufficient permissions" }); next(); }; // router.delete("/:id", requireAuth, can("todo:delete"), loadTodo, todoController.remove);

Adding a new role now means editing one table, not dozens of routes. Libraries such as CASL or Casbin extend this idea with conditions ("editors may publish only drafts they created") and can evaluate the same rules on the frontend to hide buttons.

Common mistakes

  • Trusting a role claim inside a long-lived JWT. When roles change, either re-read the user from the database in requireAuth or keep token lifetimes short.
  • Checking permissions only in the UI. Hidden buttons are not security; the API must enforce every rule.
  • Accepting req.body.owner on create or update. Set the owner from req.user.id on the server and strip the field from input.
Quick Quiz
Question 1 of 3

A logged-in editor calls an admin-only endpoint. Which status should the API return?

Key Takeaways

  • Authorization runs after authentication and depends on req.user; return 401 without credentials and 403 when the action is not allowed.
  • requireRole(...roles) protects routes declaratively; router.use() guards an entire router.
  • Ownership checks load the resource once in middleware and expose it on req; hide private resources with a 404.
  • Scope collection queries by owner in the service layer; guards alone cannot protect lists.
  • A role-to-permission map keeps rules in one place and scales better than role names in routes.

Next lesson: Security: Helmet, CORS and Rate Limiting — harden HTTP headers, control which origins may call your API, and throttle abusive clients.

Authorization: Roles and Permission Middleware - Express.js | CodeYourCraft | CodeYourCraft