Most role-based access control tutorials hand you a checkRole('admin') middleware and call it a day. That works fine for a side project. It falls apart the moment a paying customer asks: "Can you create a custom role that can view reports but not export them?" — and you realise the only answer is a code change and a redeployment.
This guide builds RBAC the way production SaaS products need it: permissions stored as data, evaluated at runtime, with no redeploy required when roles change.
Why "Permissions as Code" Is a Trap
The classic pattern looks like this:
// ❌ Fragile: permissions baked into source code
const requireRole = (role) => (req, res, next) => {
if (req.user.role !== role) return res.status(403).json({ error: 'Forbidden' });
next();
};
router.delete('/users/:id', requireRole('admin'), deleteUser);
This pattern has three compounding problems for multi-tenant SaaS:
- It couples roles to deployments. New role? Open a PR, wait for CI, ship a release.
- It cannot support per-tenant customisation. Tenant A's "manager" and Tenant B's "manager" often need different capabilities.
- It grows into an unmaintainable tangle. Within months you have
requireRole('admin'),requireRole('superadmin'), andrequireAnyRole(['editor', 'admin'])scattered across hundreds of routes.
The fix is to stop treating permissions as logic and start treating them as records in a database.
The Data Model
You need three core concepts: roles, permissions, and the binding that maps them together. Add a tenant dimension and you get full multi-tenancy.
tenants (id, name, ...)
roles (id, tenant_id, name, description)
permissions (id, action, resource) ← e.g. "delete", "invoice"
role_permissions (role_id, permission_id)
user_roles (user_id, role_id, tenant_id)
A permission row represents one discrete capability: { action: "export", resource: "reports" }. A role is simply a named bucket of those capabilities. Because both live in the database, a super-admin UI can create new roles, assign permissions, and remove them — all without touching application code.
Loading Permissions at Request Time
Build a single authorisation service that resolves a user's effective permissions from the database on every request (use an in-process cache with a short TTL to avoid hammering the DB):
// authz.service.js
const { redis } = require('../lib/cache');
const db = require('../lib/db');
async function getPermissions(userId, tenantId) {
const cacheKey = `perms:${tenantId}:${userId}`;
const cached = await redis.get(cacheKey);
if (cached) return JSON.parse(cached);
const rows = await db.query(`
SELECT p.action, p.resource
FROM user_roles ur
JOIN role_permissions rp ON rp.role_id = ur.role_id
JOIN permissions p ON p.id = rp.permission_id
WHERE ur.user_id = $1 AND ur.tenant_id = $2
`, [userId, tenantId]);
const perms = rows.map(r => `${r.action}:${r.resource}`);
await redis.setex(cacheKey, 60, JSON.stringify(perms)); // 60s TTL
return perms;
}
module.exports = { getPermissions };
A permission string like "delete:invoice" is trivial to check and easy to audit.
A Flexible Middleware That Reads from Data
Now the middleware becomes a thin wrapper around the service:
// middleware/can.js
const { getPermissions } = require('../services/authz.service');
const can = (action, resource) => async (req, res, next) => {
try {
const perms = await getPermissions(req.user.id, req.tenant.id);
if (!perms.includes(`${action}:${resource}`)) {
return res.status(403).json({ error: 'Forbidden' });
}
next();
} catch (err) {
next(err);
}
};
module.exports = can;
Routes now read like a security policy document:
router.delete('/invoices/:id', can('delete', 'invoice'), deleteInvoice);
router.get('/reports/export', can('export', 'report'), exportReport);
Adding a new permission requires inserting one database row — nothing else changes.
Handling Multi-Tenancy Correctly
The tenant_id column on user_roles is what makes this properly multi-tenant. A user can hold the role billing-admin in Tenant A and read-only in Tenant B simultaneously. The authorisation query always scopes to both user_id and tenant_id, so there is no bleed between tenants.
Cache Invalidation
When a role's permissions change, you must bust the cache for every user holding that role. A lightweight approach: publish a role.updated event (via Redis pub/sub or a message queue) and have all running instances clear matching cache keys. A more aggressive approach is a short TTL (30–60 seconds) alone — acceptable for most SaaS permission changes, which are administrative actions, not real-time events.
Supercharging with Wildcard Permissions
For platform admins who should bypass all checks, add a wildcard convention:
if (perms.includes('*:*')) return next(); // internal super-admin
if (perms.includes(`*:${resource}`)) return next(); // resource-level wildcard
This keeps the middleware generic while allowing coarse-grained escape hatches for internal tooling.
What to Expose in Your Admin UI
A data-driven RBAC system is only useful if operators can manage it without engineering help. Your admin panel should expose at minimum:
- Role management — create, rename, and deactivate roles per tenant
- Permission assignment — a matrix view of roles vs. permissions with toggle checkboxes
- User role assignment — assign or revoke roles per user per tenant
- Audit log — who changed what role/permission and when
The audit log is non-negotiable for any SaaS product operating in regulated industries or with enterprise contracts.
Common Pitfalls
- Over-granular permissions. Thirty permissions per resource creates management fatigue. Start coarse (
read,write,delete,export) and add granularity only when a real customer need demands it. - Forgetting service-to-service calls. Internal microservices calling your API need their own machine roles with explicit permissions — do not whitelist them by IP alone.
- Skipping permission checks on bulk endpoints. A user might not be able to delete a single record but can trigger a "delete selected" batch. Each action needs its own permission check regardless of surface.
Why This Matters for Your Project
If you are building a multi-tenant SaaS product, the question is not whether your customers will eventually request custom roles — it is when. Architecting RBAC as a data problem from day one means that conversation becomes a product feature you can demo, not a sprint of rework you need to schedule. It also makes your security posture auditable, your onboarding faster, and your enterprise sales cycle shorter. The engineering investment is modest; the operational leverage is substantial.





