RBAC in MERN — Stop Hardcoding Role Checks
Most Role Based Access Control(RBAC) tutorials show you the easy version. Here is what it looks like in a real project
You have JWT authentication working. Login works, protected routes work, req.user is populated on every request. Then the next requirement comes in.
Admins need to delete users. Managers need to approve records. Regular users can only view their own data.
The first instinct looks like this:
exports.deleteUser = async (req, res) => {
if (req.user.role !== 'admin') {
return res.status(403).json({ message: 'Access denied' });
}
// delete logic
};
That works. Until it does not. Three months later that check is copy-pasted across fourteen controllers, a new role gets added, and now there are fourteen files to update. This article covers the pattern that prevents that problem from the start.
Authentication vs Authorisation
These two words get used interchangeably but they describe different problems.
Authentication answers: who are you? JWT handles this. The token proves identity.
Authorisation answers: what are you allowed to do? RBAC handles this. The role determines access.
Most tutorials stop at authentication. The moment a real project adds a second type of user, the authorisation problem surfaces and there is no clean pattern in place to deal with it.
Step 1 — Update the User Model
The first change is in the schema. The User model needs a roles field.
Two common approaches: a single role string, or an array of roles.
Single role — simpler but limited:
role: {
type: String,
enum: ['user', 'manager', 'admin'],
default: 'user'
}
Array of roles — more flexible:
roles: {
type: [String],
enum: ['user', 'manager', 'admin'],
default: ['user']
}
For most real projects, the array approach scales better. A user might be both a manager and an auditor. A single string field cannot handle that without adding new enum values every time requirements change.
Here is the updated User model:
// models/User.js
const mongoose = require('mongoose');
const bcrypt = require('bcryptjs');
const userSchema = new mongoose.Schema({
name: {
type: String,
required: [true, 'Please add a name']
},
email: {
type: String,
required: [true, 'Please add an email'],
unique: true,
lowercase: true
},
password: {
type: String,
required: [true, 'Please add a password'],
minlength: 6,
select: false
},
roles: {
type: [String],
enum: ['user', 'manager', 'admin'],
default: ['user']
}
}, { timestamps: true });
userSchema.pre('save', async function(next) {
if (!this.isModified('password')) return next();
const salt = await bcrypt.genSalt(10);
this.password = await bcrypt.hash(this.password, salt);
next();
});
userSchema.methods.matchPassword = async function(enteredPassword) {
return await bcrypt.compare(enteredPassword, this.password);
};
module.exports = mongoose.model('User', userSchema);
Step 2 — The Reusable Role Middleware
This is the core of the pattern. Instead of checking roles inside every controller, a single middleware handles it. The middleware accepts roles as arguments and returns a function that checks the current user against those roles.
// middleware/roleMiddleware.js
const authorise = (...roles) => {
return (req, res, next) => {
if (!req.user) {
return res.status(401).json({ message: 'Not authorised' });
}
const userRoles = req.user.roles;
const hasRole = roles.some(role => userRoles.includes(role));
if (!hasRole) {
return res.status(403).json({
message: 'You do not have permission to perform this action'
});
}
next();
};
};
module.exports = { authorise };
Three things worth paying attention to here.
The spread operator ...roles means the middleware accepts any number of role arguments. Pass one role or five, the same middleware handles both.
roles.some(role => userRoles.includes(role)) checks if the user has at least one of the required roles. A user with ['manager', 'user'] roles passes a check for authorise('admin', 'manager') because manager appears in both lists.
The middleware checks for req.user first. This assumes the JWT middleware has already run and populated req.user. If it has not, the request gets a 401 rather than a cryptic undefined error.
Step 3 — Chaining JWT and Role Middleware on Routes
The JWT middleware runs first, populates req.user, then the role middleware checks the roles. Both sit on the route, in order.
// routes/userRoutes.js
const express = require('express');
const router = express.Router();
const { protect } = require('../middleware/authMiddleware');
const { authorise } = require('../middleware/roleMiddleware');
const {
getAllUsers,
deleteUser,
updateUserRole,
getProfile
} = require('../controllers/userController');
// Any authenticated user
router.get('/profile', protect, getProfile);
// Managers and admins only
router.get('/all', protect, authorise('admin', 'manager'), getAllUsers);
// Admins only
router.delete('/:id', protect, authorise('admin'), deleteUser);
router.patch('/:id/role', protect, authorise('admin'), updateUserRole);
module.exports = router;
The route reads like a clear statement of intent. protect handles authentication. authorise('admin') handles authorisation. The controller does neither — it just handles the business logic, knowing that by the time it runs, both checks have already passed.
Step 4 — Controllers Stay Clean
With the middleware handling role checks, controllers carry no role logic at all.
// controllers/userController.js
const User = require('../models/User');
exports.deleteUser = async (req, res) => {
try {
const user = await User.findById(req.params.id);
if (!user) {
return res.status(404).json({ message: 'User not found' });
}
await user.deleteOne();
res.json({ message: 'User removed' });
} catch (error) {
res.status(500).json({ message: error.message });
}
};
exports.updateUserRole = async (req, res) => {
try {
const user = await User.findById(req.params.id);
if (!user) {
return res.status(404).json({ message: 'User not found' });
}
user.roles = req.body.roles;
await user.save();
res.json({ message: 'User roles updated', roles: user.roles });
} catch (error) {
res.status(500).json({ message: error.message });
}
};
No if req.user.role !== admin anywhere. The controller does one thing ,handle the request. Role enforcement belongs to the middleware, not here.
Step 5 — When Roles Are Not Enough
For most projects, the roles pattern above covers everything. Some applications need more granular control.
Consider a content platform where editors can publish their own articles but not others. A role check alone cannot handle this , the issue is not the role, it is ownership.
This is where resource level permission checks come in. Instead of checking roles, you check whether the requesting user owns the resource:
// A simple ownership check inside a controller
exports.updateArticle = async (req, res) => {
try {
const article = await Article.findById(req.params.id);
const isOwner = article.author.toString() === req.user._id.toString();
const isAdmin = req.user.roles.includes('admin');
if (!isOwner && !isAdmin) {
return res.status(403).json({ message: 'Not authorised to update this article' });
}
// update logic
} catch (error) {
res.status(500).json({ message: error.message });
}
};
The role middleware handles broad access control. Ownership checks live inside the specific controller where the context exists. Both patterns work together without getting in each other's way.
Common Mistakes
| Mistake | Why It Hurts | Fix |
|---|---|---|
| Hardcoding role checks in controllers | Scattered across the codebase, breaks when roles change | Use a reusable authorise middleware |
| Single role string instead of array | Cannot handle users with multiple roles | Store roles as an array |
| Checking roles before JWT middleware runs | req.user is undefined, causes cryptic errors |
Always run protect before authorise |
| Returning 401 for wrong role | 401 means unauthenticated, 403 means unauthorised | Return 403 when the user is authenticated but lacks permission |
| No default role on registration | New users have no role, all checks fail | Always set a default role in the schema |
The Full Middleware Chain
The complete flow for a protected, role-restricted route:
Request
→ protect middleware (verifies JWT, populates req.user)
→ authorise middleware (checks req.user.roles against required roles)
→ controller (handles business logic, no auth concerns)
→ Response
Each layer has one responsibility. Nothing leaks into the wrong place.
This article is part of a series on building authentication and authorisation in MERN from scratch. The first article covers JWT authentication, including the mistakes that are easy to miss the first time round — you can read it here: JWT Authentication in a MERN App — What I Got Wrong the First Time
A MERN Timesheet and Project Allocation starter kit is currently in progress, a production-ready boilerplate that includes JWT auth, role based access control, and project allocation workflows out of the box. It will be shared here when ready.
Follow along on Hashnode to catch it when it drops.
Written by a developer who once copy pasted an admin check into eleven controllers before realising there was a better way. If something here is wrong, say so, that is how this gets better.


