Role-Based Access Control (RBAC) in Node.js and React
RBAC answers one core question in every application: who is allowed to do what?
As soon as your app has multiple user types, such as admins, editors, and viewers, access control should become explicit. Without it, authorization rules spread across controllers and components, become hard to audit, and eventually create security bugs.
Role-Based Access Control (RBAC) solves this by assigning users to roles and mapping each role to allowed actions. It gives you a clear policy that is easier to reason about, test, and maintain.
Use RBAC when:
- Your user groups are stable (for example, admin/editor/viewer).
- Most permissions are shared by groups rather than customized per user.
- You want predictable authorization checks on both API and UI layers.
In this guide, you will implement RBAC in three practical layers:
- Define roles and permissions.
- Enforce permissions in Node.js backend routes.
- Reflect permissions in React route guards and UI.
1. Define Roles and Permissions
Start by centralizing your authorization model. Keep it in one place so backend and frontend can follow the same policy.
// permissions.js
const roles = {
admin: {
can: ['create', 'edit', 'delete', 'view'],
},
editor: {
can: ['create', 'edit', 'view'],
},
viewer: {
can: ['view'],
},
};
module.exports = { roles };Role summary:
- Admin: full access.
- Editor: create, edit, and view.
- Viewer: view only.
2. Attach the User Role During Authentication
After authentication (for example, JWT verification), attach the role to req.user so route middleware can evaluate permissions consistently.
// authMiddleware.js (example shape)
// req.user should be available before authorization middleware runs.
req.user = {
id: decoded.sub,
role: decoded.role,
};3. Enforce RBAC in Node.js Routes
Authorization must be enforced on the backend. Frontend checks improve UX, but they do not provide security.
// roleMiddleware.js
const { roles } = require('./permissions');
const checkPermission = (action) => {
return (req, res, next) => {
const userRole = req.user?.role;
if (!userRole || !roles[userRole]) {
return res.status(401).json({ message: 'Unauthorized' });
}
const permissions = roles[userRole].can;
if (!permissions.includes(action)) {
return res.status(403).json({ message: 'Forbidden' });
}
next();
};
};
module.exports = { checkPermission };Use the middleware at route level:
// app.js
const express = require('express');
const { checkPermission } = require('./roleMiddleware');
const app = express();
app.post('/content', checkPermission('create'), (req, res) => {
res.send('Content created');
});
app.delete('/content/:id', checkPermission('delete'), (req, res) => {
res.send('Content deleted');
});4. Handle Role-Based Routing in React
In React, use role-aware route guards to guide users away from pages they should not access.
// auth.js
import { jwtDecode } from 'jwt-decode';
export const getUserRole = () => {
const token = localStorage.getItem('token');
if (!token) return null;
try {
const decoded = jwtDecode(token);
return decoded.role ?? null;
} catch {
return null;
}
};// ProtectedRoute.jsx (React Router v6)
import { Navigate, Outlet } from 'react-router-dom';
import { getUserRole } from './auth';
const ProtectedRoute = ({ allowedRoles }) => {
const role = getUserRole();
if (!role) return <Navigate to="/login" replace />;
if (!allowedRoles.includes(role)) return <Navigate to="/unauthorized" replace />;
return <Outlet />;
};
export default ProtectedRoute;// App.jsx
import { BrowserRouter, Route, Routes } from 'react-router-dom';
import ProtectedRoute from './ProtectedRoute';
import AdminDashboard from './AdminDashboard';
import EditorPage from './EditorPage';
import ViewerPage from './ViewerPage';
import Unauthorized from './Unauthorized';
const App = () => (
<BrowserRouter>
<Routes>
<Route element={<ProtectedRoute allowedRoles={['admin']} />}>
<Route path="/admin" element={<AdminDashboard />} />
</Route>
<Route element={<ProtectedRoute allowedRoles={['admin', 'editor']} />}>
<Route path="/editor" element={<EditorPage />} />
</Route>
<Route
element={<ProtectedRoute allowedRoles={['admin', 'editor', 'viewer']} />}
>
<Route path="/viewer" element={<ViewerPage />} />
</Route>
<Route path="/unauthorized" element={<Unauthorized />} />
</Routes>
</BrowserRouter>
);
export default App;5. Show or Hide UI by Permission
Use role checks to improve UX, such as hiding actions users cannot perform.
// DeleteButton.jsx
import { getUserRole } from './auth';
const DeleteButton = ({ onDelete }) => {
const role = getUserRole();
return role === 'admin' ? <button onClick={onDelete}>Delete User</button> : null;
};
export default DeleteButton;This is a convenience for users, not a security boundary. The API must still enforce authorization.
6. Practical End-to-End Example
If your admin dashboard includes user deletion:
- Frontend: show the delete button only for admin users.
- Backend: protect
DELETE /users/:idwithcheckPermission('delete').
app.delete('/users/:id', checkPermission('delete'), (req, res) => {
res.send('User deleted');
});Common RBAC Mistakes to Avoid
- Enforcing authorization only in React and not on backend APIs.
- Duplicating permission logic across files instead of using one policy map.
- Returning generic success responses when authorization fails.
- Hard-coding role strings in many places without shared constants.
Conclusion
RBAC helps you keep authorization predictable and auditable across your stack.
For a reliable implementation:
- Define roles and permissions once.
- Enforce checks in backend middleware on every protected route.
- Mirror those rules in React for better navigation and UX.
With this structure, your app remains secure as features and teams grow.