How to Implement Role-Based Access Control in a Multi-Tenant SaaS App
Flat permission lists break the moment a user from Tenant A can accidentally read Tenant B's data. It is not a theoretical edge case — it is one of the most common privilege-escalation vectors in SaaS products, and it almost always traces back to an authorization model that was not designed with tenancy in mind from the start.
This guide walks through a production-grade approach to Role-Based Access Control (RBAC) in a multi-tenant Node.js application backed by PostgreSQL. You will see the exact schema, the permission matrix pattern, and the Express middleware layer that enforces it — all scoped correctly to individual tenants.
Why Tenant-Scoped RBAC Is Different
In a single-tenant app, a role like admin is unambiguous. In a multi-tenant SaaS, "admin" means admin of what, exactly? A user can be an admin inside Tenant A and a read-only member inside Tenant B. The role is not a property of the user — it is a property of the relationship between the user and the tenant.
This distinction has cascading consequences for your schema, your middleware, and your token design.
Schema Design: The Permission Matrix
Start with four core tables.
-- Tenants
CREATE TABLE tenants (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name TEXT NOT NULL,
created_at TIMESTAMPTZ DEFAULT NOW()
);
-- Global user registry (no permissions here)
CREATE TABLE users (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
email TEXT UNIQUE NOT NULL,
password_hash TEXT NOT NULL,
created_at TIMESTAMPTZ DEFAULT NOW()
);
-- Roles are defined per tenant
CREATE TABLE roles (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
name TEXT NOT NULL,
UNIQUE (tenant_id, name)
);
-- The permission matrix: role × resource × action
CREATE TABLE role_permissions (
role_id UUID NOT NULL REFERENCES roles(id) ON DELETE CASCADE,
resource TEXT NOT NULL, -- e.g. 'invoice', 'report', 'user'
action TEXT NOT NULL, -- e.g. 'read', 'write', 'delete'
PRIMARY KEY (role_id, resource, action)
);
-- The join that scopes a user to a tenant with a role
CREATE TABLE tenant_memberships (
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
role_id UUID NOT NULL REFERENCES roles(id),
PRIMARY KEY (user_id, tenant_id)
);
A few design decisions worth noting:
- Roles live inside tenants. A
rolesrow withtenant_idmeans Tenant A's "manager" role and Tenant B's "manager" role are entirely separate entities with independent permission sets. - Permissions are resource-action pairs. This is the matrix.
(invoice, read)and(invoice, delete)are distinct rows. You can add or revoke granular capabilities without touching role names. tenant_membershipsis the trust boundary. A user without a row in this table for a given tenant has zero access to that tenant — no exceptions.
Embedding Tenant Context in the JWT
When a user authenticates, issue a JWT that includes both userId and tenantId. The tenant context must be an explicit, validated field — not something derived from a request header that a client could spoof.
// On login, after verifying credentials and resolving the active tenant
const token = jwt.sign(
{ userId: user.id, tenantId: membership.tenant_id },
process.env.JWT_SECRET,
{ expiresIn: '8h' }
);
If your product allows users to switch between tenants, issue a new token on each switch. Never allow client-side mutation of the tenantId claim.
The Authorization Middleware
With the schema in place, you can write a clean, reusable middleware factory that checks the permission matrix on every request.
// middleware/authorize.js
const { pool } = require('../db');
function authorize(resource, action) {
return async (req, res, next) => {
const { userId, tenantId } = req.user; // populated by JWT middleware
const { rows } = await pool.query(
`SELECT 1
FROM tenant_memberships tm
JOIN role_permissions rp ON rp.role_id = tm.role_id
WHERE tm.user_id = $1
AND tm.tenant_id = $2
AND rp.resource = $3
AND rp.action = $4
LIMIT 1`,
[userId, tenantId, resource, action]
);
if (rows.length === 0) {
return res.status(403).json({ error: 'Forbidden' });
}
next();
};
}
module.exports = authorize;
Usage at the route level is declarative and readable:
const authorize = require('../middleware/authorize');
router.get('/invoices', authorize('invoice', 'read'), listInvoices);
router.post('/invoices', authorize('invoice', 'write'), createInvoice);
router.delete('/invoices/:id', authorize('invoice', 'delete'), deleteInvoice);
The single SQL query does all the work: it joins the user's membership to the permission matrix, scoped by both user_id and tenant_id. There is no way for a user in Tenant A to pass this check for a resource in Tenant B because the tenantId is sourced from the verified JWT — not from user input.
Preventing Privilege Escalation
Privilege escalation in RBAC usually happens in one of three ways:
1. Role assignment without boundary checks
If your API lets an admin assign roles, make sure you validate that the role being assigned belongs to the same tenant as the admin performing the action. A global role_id lookup without a tenant filter is an escalation vector.
// Always scope role lookups to the current tenant
const role = await db.query(
'SELECT id FROM roles WHERE id = $1 AND tenant_id = $2',
[roleId, req.user.tenantId]
);
2. Cross-tenant object access
Every data query for a tenant-owned resource must include tenant_id in the WHERE clause — not just in the authorization check. Authorization middleware confirms the user can act; the query itself must confirm the object belongs to their tenant.
3. Cached stale permissions
If you cache permission lookups in Redis for performance, tie cache invalidation to role or membership changes. A user whose role was downgraded should lose access within seconds, not hours.
Seeding Default Roles
When a new tenant signs up, seed sensible defaults automatically:
- Owner — full access to all resources and actions
- Admin — full access except billing and tenant deletion
- Member — read access to most resources, write access to their own objects
- Viewer — read-only across the board
Store these as named seeds you can replay consistently. This also gives you a baseline for integration tests.
Why This Matters for Your Project
If you are building a SaaS product that serves multiple clients from a single codebase, getting authorization right at the schema level is not optional — it is the difference between a product you can confidently demo to enterprise clients and one that fails a basic security review. The pattern above scales from two tenants to ten thousand without architectural changes, because the permission matrix lives in the database where it can be queried, audited, and updated without a deployment. Whether you are starting a new product or retrofitting an existing one, treating tenant membership as the atomic unit of authorization is the structural decision that prevents the most costly bugs down the line.





