How to Build a Role-Based Access Control System in Node.js
Most SaaS applications outgrow a simple isAdmin boolean somewhere around the third feature request. A product manager wants read-only users. A client wants a "billing-only" role. Your enterprise customer wants custom roles they can configure themselves. If your authorization logic is scattered across route handlers by then, you have a problem.
The answer is Role-Based Access Control — and you do not need a heavyweight library to implement it well. A clean, database-backed RBAC system built from scratch is more transparent, more maintainable, and easier to extend than most off-the-shelf solutions. Here is how to design one that holds up as your product grows.
The Core Concepts to Get Right First
RBAC boils down to three entities and the relationships between them:
- Users are assigned one or more Roles
- Roles hold a collection of Permissions
- Permissions represent discrete actions (e.g.,
invoices:read,users:delete)
The power of this model is that you never assign permissions directly to users in normal operation. You shape roles, assign roles to users, and let the system resolve what a user can do at runtime.
One addition worth planning for early: permission inheritance. A SuperAdmin role should not need every permission listed explicitly — it should inherit from Admin, which inherits from Editor, and so on. This keeps your role definitions DRY and your database tidy.
Schema Design: The Decisions That Matter
Use three core tables and two join tables. Keeping permissions as their own rows (not an array column or a bitmask) is the single most important schema decision — it makes querying, auditing, and UI management dramatically easier.
-- Core tables
CREATE TABLE roles (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name VARCHAR(64) UNIQUE NOT NULL,
parent_role_id UUID REFERENCES roles(id) ON DELETE SET NULL,
created_at TIMESTAMPTZ DEFAULT now()
);
CREATE TABLE permissions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
action VARCHAR(128) UNIQUE NOT NULL -- e.g. "invoices:delete"
);
-- Join tables
CREATE TABLE role_permissions (
role_id UUID REFERENCES roles(id) ON DELETE CASCADE,
permission_id UUID REFERENCES permissions(id) ON DELETE CASCADE,
PRIMARY KEY (role_id, permission_id)
);
CREATE TABLE user_roles (
user_id UUID REFERENCES users(id) ON DELETE CASCADE,
role_id UUID REFERENCES roles(id) ON DELETE CASCADE,
PRIMARY KEY (user_id, role_id)
);
The parent_role_id self-reference on the roles table is what enables inheritance. A recursive CTE can walk the hierarchy at query time without denormalizing data.
Resolving Permissions with Inheritance
When a user attempts an action, you need their full effective permission set — including inherited ones. A single recursive query handles this cleanly in PostgreSQL:
WITH RECURSIVE role_hierarchy AS (
SELECT r.id FROM roles r
JOIN user_roles ur ON ur.role_id = r.id
WHERE ur.user_id = $1
UNION
SELECT r.id FROM roles r
JOIN role_hierarchy rh ON rh.id = r.parent_role_id -- wait, this should traverse upward
)
In Node.js, wrap this in a service function that caches the result in Redis (or even in-process with a short TTL) keyed by userId. Permission sets rarely change mid-session, and the cache eliminates repeated database round-trips on every request.
// permissionService.js
async function getUserPermissions(userId) {
const cacheKey = `perms:${userId}`;
const cached = await redis.get(cacheKey);
if (cached) return JSON.parse(cached);
const { rows } = await db.query(`
WITH RECURSIVE role_hierarchy AS (
SELECT r.id, r.parent_role_id
FROM roles r JOIN user_roles ur ON ur.role_id = r.id
WHERE ur.user_id = $1
UNION
SELECT r.id, r.parent_role_id
FROM roles r JOIN role_hierarchy rh ON r.id = rh.parent_role_id
)
SELECT DISTINCT p.action
FROM permissions p
JOIN role_permissions rp ON rp.permission_id = p.id
JOIN role_hierarchy rh ON rh.id = rp.role_id
`, [userId]);
const permissions = rows.map(r => r.action);
await redis.set(cacheKey, JSON.stringify(permissions), 'EX', 300);
return permissions;
}
Middleware Guards in Express
With the permission resolver in place, writing route guards becomes a one-liner. Create a factory function that returns middleware:
// authorize.js
const { getUserPermissions } = require('./permissionService');
function authorize(...requiredPermissions) {
return async (req, res, next) => {
try {
const userPermissions = await getUserPermissions(req.user.id);
const hasAll = requiredPermissions.every(p => userPermissions.includes(p));
if (!hasAll) return res.status(403).json({ error: 'Forbidden' });
next();
} catch (err) {
next(err);
}
};
}
module.exports = authorize;
Usage on any route is clean and self-documenting:
router.delete('/invoices/:id', authenticate, authorize('invoices:delete'), deleteInvoice);
router.get('/reports', authenticate, authorize('reports:read', 'analytics:access'), getReports);
Practical Patterns for SaaS
A few design choices pay off significantly at scale:
Use namespaced permission strings
Format permissions as resource:action (users:invite, billing:manage). This makes it trivial to group and filter permissions in admin UIs and keeps naming collisions impossible.
Never hard-code role names in your application code
Your code should only reference permission strings. Hard-coding role === 'admin' couples your logic to role names that clients will want to rename or restructure. Permissions are the stable interface; roles are the configurable grouping on top.
Invalidate the cache on role changes
Any time a role is modified or a user's role assignment changes, immediately purge their cached permission set. A simple event in your role service handles this.
Audit log permission checks on sensitive actions
For compliance-heavy SaaS products, log every 403 response with the user ID, the required permission, and the timestamp. This is far easier to implement here, at the authorization layer, than retrofitting it later.
Why This Matters for Your Project
Authorization is one of those systems that looks simple early and becomes a liability fast if it was never designed to grow. Building RBAC from first principles — with a clean schema, recursive inheritance, cached resolution, and composable middleware — gives you a foundation that survives enterprise feature requests, compliance audits, and multi-tenant complexity. Whether you are building a fintech dashboard in Accra or a logistics SaaS for a regional market, the teams that invest in proper access control early spend far less time untangling permission bugs when the product scales.





