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'), and requireAnyRole(['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.