Role-Based Access Control (RBAC) in Node.js

Role-Based Access Control (RBAC) in Node.js
Authentication proves who a user is. Authorization decides what they can do. Most APIs need both — and confusing the two is one of the most common security mistakes in web development.
Role-Based Access Control is the right starting point for the vast majority of applications. It is simple enough to implement in an afternoon and expressive enough to model most real-world permission requirements. This module builds a complete RBAC system on top of the JWT authentication from Module 18.
This is Module 20 of the Node.js Full‑Stack Developer course.
Defining Roles and Permissions
Start by mapping out exactly what each role can do. A permission matrix is the clearest way to express this:
user editor admin
─────────────────────────────────────────
GET /posts ✅ ✅ ✅
GET /posts/:id ✅ ✅ ✅
POST /posts ❌ ✅ ✅
PUT /posts/:id ❌ own only ✅
DELETE /posts/:id ❌ ❌ ✅
GET /users ❌ ❌ ✅
DELETE /users/:id ❌ ❌ ✅
GET /admin/* ❌ ❌ ✅Write this down before touching code. The matrix becomes your test specification.
Defining Roles as Constants
// lib/roles.js
export const ROLES = Object.freeze({
USER: 'user',
EDITOR: 'editor',
ADMIN: 'admin',
});
// Role hierarchy — higher index = more permissions
export const ROLE_HIERARCHY = [ROLES.USER, ROLES.EDITOR, ROLES.ADMIN];
/**
* Returns true if userRole is at least as privileged as requiredRole.
*/
export function hasMinimumRole(userRole, requiredRole) {
return ROLE_HIERARCHY.indexOf(userRole) >= ROLE_HIERARCHY.indexOf(requiredRole);
}The requireRole Middleware
// middleware/rbac.js
import { AppError } from '../lib/errors.js';
import { ROLE_HIERARCHY } from '../lib/roles.js';
/**
* Require the user to have at least the specified role.
* Must be used after requireAuth (which sets req.user).
*
* @param {...string} roles - Accepted roles (any one is sufficient)
*/
export function requireRole(...roles) {
return (req, res, next) => {
if (!req.user) {
return next(new AppError('Authentication required', 401));
}
const userRole = req.user.role;
const allowed = roles.some(
role => ROLE_HIERARCHY.indexOf(userRole) >= ROLE_HIERARCHY.indexOf(role)
);
if (!allowed) {
return next(
new AppError(
`Access denied. Required role: ${roles.join(' or ')}. Your role: ${userRole}`,
403
)
);
}
next();
};
}
/**
* Require the user to have exactly one of the specified roles (no hierarchy).
*/
export function requireExactRole(...roles) {
return (req, res, next) => {
if (!req.user) {
return next(new AppError('Authentication required', 401));
}
if (!roles.includes(req.user.role)) {
return next(new AppError('Access denied', 403));
}
next();
};
}Using requireRole on Routes
// features/posts/posts.router.js
import express from 'express';
import * as controller from './posts.controller.js';
import { requireAuth } from '../../middleware/auth.js';
import { requireRole } from '../../middleware/rbac.js';
import { validate } from '../../middleware/validate.js';
import { createPostSchema, updatePostSchema } from './posts.schema.js';
import { ROLES } from '../../lib/roles.js';
const router = express.Router();
// Public — no auth required
router.get('/', controller.list);
router.get('/:id', controller.getById);
// Authenticated — any logged-in user
router.post(
'/',
requireAuth,
requireRole(ROLES.EDITOR),
validate(createPostSchema),
controller.create
);
// Editor can update their own posts; admin can update any
router.put(
'/:id',
requireAuth,
requireRole(ROLES.EDITOR),
validate(updatePostSchema),
controller.update
);
// Admin only
router.delete(
'/:id',
requireAuth,
requireRole(ROLES.ADMIN),
controller.remove
);
export default router;// features/admin/admin.router.js
import express from 'express';
import { requireAuth } from '../../middleware/auth.js';
import { requireRole } from '../../middleware/rbac.js';
import { ROLES } from '../../lib/roles.js';
import * as adminController from './admin.controller.js';
const router = express.Router();
// Apply auth + admin role to every route in this router
router.use(requireAuth);
router.use(requireRole(ROLES.ADMIN));
router.get('/users', adminController.listUsers);
router.delete('/users/:id', adminController.deleteUser);
router.get('/stats', adminController.getStats);
export default router;Resource Ownership Checks
A role check alone is not enough for "edit your own resource" scenarios. You need an ownership check after loading the resource:
// middleware/ownership.js
import { AppError } from '../lib/errors.js';
import { ROLES } from '../lib/roles.js';
/**
* Factory that returns middleware checking if the requesting user
* owns the resource, or is an admin (who can access any resource).
*
* @param {Function} fetchResource - async (id) => resource | null
* @param {string} ownerField - field on the resource holding the owner's userId
*/
export function requireOwnerOrAdmin(fetchResource, ownerField = 'authorId') {
return async (req, res, next) => {
try {
const resource = await fetchResource(req.params.id);
if (!resource) {
return next(new AppError('Resource not found', 404));
}
const isAdmin = req.user.role === ROLES.ADMIN;
const isOwner = resource[ownerField]?.toString() === req.user.sub;
if (!isAdmin && !isOwner) {
return next(new AppError('You do not have permission to modify this resource', 403));
}
// Attach the resource so the controller doesn't have to fetch it again
req.resource = resource;
next();
} catch (err) {
next(err);
}
};
}Usage:
// features/posts/posts.router.js
import { requireOwnerOrAdmin } from '../../middleware/ownership.js';
import { getPostById } from './posts.service.js';
router.put(
'/:id',
requireAuth,
requireRole(ROLES.EDITOR),
requireOwnerOrAdmin(getPostById, 'authorId'),
validate(updatePostSchema),
controller.update // req.resource is already loaded
);
router.delete(
'/:id',
requireAuth,
requireOwnerOrAdmin(getPostById, 'authorId'),
controller.remove
);The controller can now use req.resource directly without a second database round-trip:
// features/posts/posts.controller.js
export async function update(req, res, next) {
try {
// req.resource was populated by requireOwnerOrAdmin middleware
const post = await postsService.updatePost(req.resource.id, req.body);
res.json(post);
} catch (err) {
next(err);
}
}Permission-Based RBAC (Fine-Grained)
For larger systems, map permissions explicitly instead of relying on role hierarchy:
// lib/permissions.js
export const PERMISSIONS = Object.freeze({
// Posts
POST_READ: 'post:read',
POST_CREATE: 'post:create',
POST_UPDATE: 'post:update',
POST_DELETE: 'post:delete',
// Users
USER_READ: 'user:read',
USER_UPDATE: 'user:update',
USER_DELETE: 'user:delete',
// Admin
ADMIN_ACCESS: 'admin:access',
STATS_VIEW: 'stats:view',
});
// Map each role to its permissions
const ROLE_PERMISSIONS = {
user: [
PERMISSIONS.POST_READ,
],
editor: [
PERMISSIONS.POST_READ,
PERMISSIONS.POST_CREATE,
PERMISSIONS.POST_UPDATE,
],
admin: Object.values(PERMISSIONS), // all permissions
};
export function can(role, permission) {
return ROLE_PERMISSIONS[role]?.includes(permission) ?? false;
}// middleware/rbac.js — permission-based variant
import { can } from '../lib/permissions.js';
import { AppError } from '../lib/errors.js';
export function requirePermission(permission) {
return (req, res, next) => {
if (!req.user) return next(new AppError('Authentication required', 401));
if (!can(req.user.role, permission)) {
return next(new AppError(`Permission denied: ${permission}`, 403));
}
next();
};
}// Usage on routes
import { PERMISSIONS } from '../../lib/permissions.js';
import { requirePermission } from '../../middleware/rbac.js';
router.post('/', requireAuth, requirePermission(PERMISSIONS.POST_CREATE), controller.create);
router.delete('/:id', requireAuth, requirePermission(PERMISSIONS.POST_DELETE), controller.remove);This approach makes the permission model explicit and auditable — you can query "what can an editor do?" by inspecting ROLE_PERMISSIONS.editor.
Self-Service Routes
Some routes are accessible by any authenticated user for their own data:
// features/users/users.router.js
// Any user can view and update their own profile
router.get('/me', requireAuth, controller.getMe);
router.put('/me', requireAuth, validate(schema), controller.updateMe);
router.delete('/me', requireAuth, controller.deleteMe);
// Only admins can manage other users
router.get('/', requireAuth, requireRole(ROLES.ADMIN), controller.list);
router.get('/:id', requireAuth, requireRole(ROLES.ADMIN), controller.getById);
router.delete('/:id', requireAuth, requireRole(ROLES.ADMIN), controller.remove);// Controller for self-service routes
export async function getMe(req, res, next) {
try {
const user = await usersService.getUserById(req.user.sub);
res.json(user);
} catch (err) {
next(err);
}
}
export async function updateMe(req, res, next) {
try {
// User can only update safe fields — not their own role
const { name, email } = req.body;
const user = await usersService.updateUser(req.user.sub, { name, email });
res.json(user);
} catch (err) {
next(err);
}
}Exposing Permissions to the Frontend
After login, return the user's permissions so the frontend can hide/show UI elements:
// features/auth/auth.service.js
import { ROLE_PERMISSIONS } from '../../lib/permissions.js';
export async function login({ email, password }) {
// ... verify credentials ...
return {
accessToken,
user: {
id: user._id,
name: user.name,
email: user.email,
role: user.role,
permissions: ROLE_PERMISSIONS[user.role] ?? [],
},
};
}The frontend uses this list to conditionally render buttons and menu items. Never trust frontend permission checks for security — they are purely for UX. The server always re-validates on every request.
Testing RBAC Middleware
// middleware/rbac.test.js
import { requireRole } from './rbac.js';
import { ROLES } from '../lib/roles.js';
function makeReqRes(role) {
const req = { user: { sub: 'user-1', role } };
const res = {};
return { req, res };
}
describe('requireRole', () => {
test('allows user with sufficient role', () => {
const { req, res } = makeReqRes(ROLES.ADMIN);
const next = jest.fn();
requireRole(ROLES.EDITOR)(req, res, next);
expect(next).toHaveBeenCalledWith(); // called with no args = success
});
test('blocks user with insufficient role', () => {
const { req, res } = makeReqRes(ROLES.USER);
const next = jest.fn();
requireRole(ROLES.EDITOR)(req, res, next);
expect(next).toHaveBeenCalledWith(expect.objectContaining({ statusCode: 403 }));
});
test('returns 401 when req.user is missing', () => {
const req = {};
const res = {};
const next = jest.fn();
requireRole(ROLES.USER)(req, res, next);
expect(next).toHaveBeenCalledWith(expect.objectContaining({ statusCode: 401 }));
});
});RBAC middleware is pure logic with no database calls — it is one of the easiest parts of your codebase to test exhaustively.
Node.js Full‑Stack Course — Module 20 of 32
Your API now has complete role-based access control. Continue to Module 21 to add social login with OAuth and Passport.js.
Summary
Role-based access control is built on a handful of composable middleware functions:
- Draw a permission matrix before writing code — it is your specification and your test cases
requireRole(...roles)checks the JWT'sroleclaim against a hierarchy — mount it afterrequireAuthrequireOwnerOrAdmin(fetchFn, ownerField)handles "edit your own resource" scenarios — attaches the resource toreq.resourceto avoid a second DB fetch- For fine-grained control, map each role to an explicit set of permissions and use
requirePermission(permission)instead ofrequireRole - Self-service routes use
req.user.subto scope operations to the requesting user — never trust auserIdfrom the request body - Return the user's permission list after login for frontend UX — but always re-validate on the server
- RBAC middleware has no side effects and no DB calls — test it exhaustively with unit tests
Continue to Module 21: OAuth & Social Login with Passport.js →
