Authentication asks who you are; authorization asks what you may do. Express 24,430 expresses the second as middleware between the route and its handler, and the two failures carry different codes: 401 means the credential is missing or invalid, 403 means it is valid and still not enough.
function requireRole(...allowed) {
return (req, res, next) => {
if (!req.isAuthenticated()) return res.status(401).json({ error: 'not_authenticated' });
if (!allowed.some((r) => req.user.roles.includes(r))) { // 403, not 401
return res.status(403).json({ error: 'forbidden', need: allowed, have: req.user.roles });
}
next();
};
}
const canEditBook = (req, res, next) => // roles alone cannot express ownership
req.user.roles.includes('admin') || req.book.authorId === req.user.id
? next() : res.status(403).json({ error: 'not_owner' });
app.delete('/books/:id', requireRole('admin'), removeBook);
app.patch('/books/:id', requireRole('author', 'admin'), loadBook, canEditBook, updateBook);$ curl -i -b ada.jar .../profile -> 200 OK {"id":"u_1","email":"ada@example.com"}
$ curl -i -b ada.jar -X DELETE .../books/7 # ada has roles ["author"]
HTTP/1.1 403 Forbidden {"error":"forbidden","need":["admin"],"have":["author"]}
$ curl -i -b root.jar -X DELETE .../books/7 # root has ["author","admin"]
HTTP/1.1 200 OK {"deleted":"7","by":"root@example.com"}Roles stop scaling the moment ownership matters. "An author may edit a book" is false as written — an author may edit their own book — which is why canEditBook compares the record to the caller. Returning 403 rather than 404 also tells an attacker the resource exists; where that matters, answer 404 instead.
Three rules keep the layer honest. Deny by default: mount the guard on the router (router.use(requireRole('admin'))) so a route added next year is protected anyway. Check on the server every time, because a hidden button in React 7,897 is decoration, not a control. And read roles from the session or a freshly verified token, never from anything the client controls.