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.
| 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.
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:
// 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();
};// 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.
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:
// 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.
When the same role needs different rights in different features, map roles to permissions instead of hard-coding role names in routes:
// 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.
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.req.body.owner on create or update. Set the owner from req.user.id on the server and strip the field from input.A logged-in editor calls an admin-only endpoint. Which status should the API return?
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.req; hide private resources with a 404.Next lesson: Security: Helmet, CORS and Rate Limiting — harden HTTP headers, control which origins may call your API, and throttle abusive clients.