RBAC Authentication System: A Complete Guide for Node.js

Software Engineer & Full-Stack Developer
Every serious application needs authentication and authorization. When I built the Yuga Yatra retail platform, I needed a system where Super Admins could manage everything, Store Managers could handle inventory, and Sales Staff could only process orders. RBAC was the answer.
The Data Model
RBAC starts with a clean data model. I use three collections: Users (credentials, profile), Roles (name, permissions array), and a many-to-many relationship between them:
// Role schema
const RoleSchema = new mongoose.Schema({
name: { type: String, enum: ['super_admin', 'admin', 'manager', 'sales', 'viewer'] },
permissions: [{
resource: { type: String, enum: ['products', 'orders', 'users', 'reports', 'settings'] },
actions: [{ type: String, enum: ['create', 'read', 'update', 'delete'] }]
}]
});
// User schema — roles as references
const UserSchema = new mongoose.Schema({
email: { type: String, unique: true },
passwordHash: String,
roles: [{ type: mongoose.Schema.Types.ObjectId, ref: 'Role' }],
refreshTokens: [String]
});JWT + Refresh Token Flow
Access tokens (short-lived, 15 minutes) carry the user ID and permissions. Refresh tokens (long-lived, 7 days) are stored in an HTTP-only cookie and in the database. When the access token expires, the client silently exchanges the refresh token for a new pair.
// Auth middleware
export async function authenticate(req, res, next) {
const token = req.headers.authorization?.split(' ')[1];
if (!token) return res.status(401).json({ error: 'No token' });
try {
const decoded = jwt.verify(token, process.env.JWT_SECRET);
const user = await User.findById(decoded.sub).populate('roles');
if (!user) return res.status(401).json({ error: 'User not found' });
req.user = user;
req.permissions = user.roles.flatMap(r => r.permissions);
next();
} catch {
return res.status(401).json({ error: 'Invalid token' });
}
}Permission Middleware
The authorization middleware checks if the user has the required permission for a specific resource and action. This is declarative — each route specifies what it needs:
// Permission middleware factory
export function authorize(resource, action) {
return (req, res, next) => {
const hasPermission = req.permissions.some(
p => p.resource === resource && p.actions.includes(action)
);
if (!hasPermission) return res.status(403).json({ error: 'Forbidden' });
next();
};
}
// Usage in routes
router.delete('/products/:id',
authenticate,
authorize('products', 'delete'),
productController.deleteProduct
);Security Best Practices
- Hash passwords with bcrypt (12 salt rounds minimum)
- Never store JWT in localStorage — use HTTP-only cookies
- Implement rate limiting on auth endpoints to prevent brute force
- Rotate refresh tokens on each use (detect token theft)
- Audit log all admin actions for compliance

Dhiraj Kumar
Dhiraj Kumar is a software engineer and full-stack developer interested in modern web technologies, artificial intelligence, automation, and agentic systems. Through his portfolio and technical writing, he explores practical approaches to building reliable software and AI-powered applications.
Enjoyed this article? Let's connect!
Read More Articles