Multi-tenant SaaS authorization: roles, permissions, and tenant scope
For developers designing or refactoring authorization in a multi-tenant SaaS. This guide shows how to model roles and permissions, where tenant scoping must be enforced, and how to avoid the common failure mode where app checks and database filtering drift apart.
TL;DR — In a multi-tenant SaaS, treat authorization as two separate decisions: what a principal may do (role/permission) and which tenant's data the request is allowed to touch (tenant scope). The single most reliable design is to enforce permissions in the application/service layer and enforce tenant scoping again in the data layer with explicit tenant predicates or database row-level security so a missed
WHERE tenant_id = ...does not become a breach. Reading time: ~7 min
What it is and where it sits
In a multi-tenant system, authorization is not just "is this user an admin?" It is at least:
- action authorization:
invoice.read,invoice.refund,user.invite - tenant authorization:
tenant_id = t_123, sometimes a set like[t_123, t_456] - sometimes resource-instance rules: "can edit only invoices they created"
The architecture mistake teams make is collapsing these into one check near the controller, then trusting every downstream query and background job to remember tenant filtering. That works until one endpoint, report query, export job, or admin tool forgets the filter.
Where it sits in the request flow:
- the IdP or session system proves identity and may carry coarse claims
- the app/API resolves tenant context and membership
- the authorization layer decides action permissions for that tenant
- the data layer enforces tenant scope again
- background workers repeat the same rules; they do not inherit safety from the web tier
What this replaces:
- hard-coded
if user.is_adminbranches spread across handlers - one global
users.rolecolumn with no tenant dimension - trusting client-supplied
tenant_idin JSON bodies - relying on "developers will remember the WHERE clause"
A typical flow looks like this:
View as text
[Browser] ---- 1. GET /api/invoices/42 ----> [API] [API] ---- 2. verify session/JWT ----> [AuthN] [API] ---- 3. load membership + perms ----> [AuthZ] [API] ---- 4. query with tenant scope ----> [Postgres] [Postgres] ---- 5. rows for tenant t_123 ----> [API] [API] ---- 6. 200/403/404 JSON ----> [Browser]
A practical rule: identity can come from your IdP, but tenant membership and effective permissions usually belong in your application database because they are app-specific and change more often than identity attributes.
How it actually works
Use one realistic model:
users(id, email, ... )tenants(id, name, ... )memberships(user_id, tenant_id, role)role_permissions(role, permission)- every tenant-owned table includes
tenant_id
Example roles:
owner:tenant.manage,billing.read,invoice.read,invoice.refund,user.invitebilling_admin:billing.read,invoice.read,invoice.refundviewer:invoice.read
End-to-end example: refunding an invoice
Request:
curl -i -X POST \
-H "Authorization: Bearer eyJ..." \
-H "X-Tenant-Id: t_123" \
https://api.example.com/v1/invoices/inv_42/refund
Step 1: authenticate the caller.
Your API verifies the session cookie or JWT signature, expiration, and audience. Output from a failed JWT validation should be boring and specific:
HTTP/1.1 401 Unauthorized
Content-Type: application/json
WWW-Authenticate: Bearer error="invalid_token", error_description="token expired"
{"error":"invalid_token"}
Do not continue to authorization if authentication failed.
Step 2: resolve tenant context.
The tenant context should come from one controlled source:
- subdomain like
t-123.app.example.com - path like
/t/t_123/... - header injected by a trusted gateway
If you accept X-Tenant-Id directly from browsers or mobile apps, treat it as a requested tenant, not truth. The server must verify the user is a member of that tenant.
Step 3: load membership and effective permissions.
For user u_9 and tenant t_123, query membership and role-derived permissions.
SELECT m.role, array_agg(rp.permission ORDER BY rp.permission) AS permissions
FROM memberships m
JOIN role_permissions rp ON rp.role = m.role
WHERE m.user_id = $1 AND m.tenant_id = $2
GROUP BY m.role;
If there is no membership row, return 403 or 404 depending on your information-leak policy. Most B2B SaaS APIs use:
403 Forbiddenwhen tenant existence is not sensitive and the caller is authenticated but not allowed404 Not Foundfor resource lookups when revealing cross-tenant existence would leak data
Step 4: check action permission.
For POST /refund, require invoice.refund. If the role is viewer, fail here:
HTTP/1.1 403 Forbidden
Content-Type: application/json
{"error":"forbidden","required_permission":"invoice.refund","tenant_id":"t_123"}
Step 5: enforce tenant scope in the query or database policy.
This is the part that prevents cross-tenant reads/writes when application code is buggy.
Application-side query:
UPDATE invoices
SET status = 'refunded', refunded_at = now()
WHERE id = $1
AND tenant_id = $2
AND status = 'paid'
RETURNING id, tenant_id, status;
If inv_42 belongs to t_999, the update affects zero rows. Your API can safely return:
HTTP/1.1 404 Not Found
Content-Type: application/json
{"error":"not_found"}
The caller does not learn whether inv_42 exists in another tenant.
Step 6: repeat the same tenant scope in workers and internal tools.
If the refund emits an event or enqueues a job, include tenant_id in the payload and validate it again in the worker before querying.
Bad job payload:
{"invoice_id":"inv_42"}
Good job payload:
{"tenant_id":"t_123","invoice_id":"inv_42","actor_user_id":"u_9"}
Without that, background jobs become the place where cross-tenant bugs reappear.
When to use it (and when not to)
| Scenario | Recommendation |
|---|---|
| B2B SaaS where one user can belong to multiple customer accounts | Use per-tenant memberships, role-to-permission mapping, and tenant scope enforced in DB queries or RLS |
| Small SaaS with one tenant per user and no shared accounts | Start with users.tenant_id plus a small permission set; keep the schema ready for memberships later |
| Internal admin backoffice touching all tenants | Use a separate admin surface and separate roles; do not bypass tenant scoping in the main app code path |
| Early MVP with 2 roles and 5 endpoints | You probably do not need a policy engine; a permission table and explicit checks are enough |
| Consumer app with no tenant concept | Do not add tenant-scoped authorization; use normal per-user/resource authorization |
| Heavy ABAC/ReBAC needs like document sharing, delegated access, org hierarchies | Consider a policy engine or graph-based model, but still keep tenant scope explicit |
You probably don't need a dedicated authorization service if:
- you have fewer than roughly 20-30 permissions
- all decisions happen in one app/backend
- roles are mostly static
- you can grep the codebase and understand every check in an hour
You probably do need stronger structure if:
- users belong to multiple tenants
- support/admin users impersonate customers
- you have exports, analytics, webhooks, and workers touching tenant data
- multiple services query the same tenant-owned tables
Trade-offs
Benefit and cost come together here.
- Per-tenant memberships instead of global roles: correct model for B2B SaaS where the same user is owner in one tenant and viewer in another. Cost: more joins, more cache keys, and more edge cases in UI tenant switching.
- Permissions instead of role names in code: lets you change role composition without rewriting handlers. Cost: permission sprawl; someone must govern names like
invoice.refundvsbilling.invoice.refund. - Database-enforced tenant scope: catches missed filters and reduces breach risk. Cost: more query discipline, trickier migrations, and extra debugging when policies hide rows instead of throwing obvious errors.
- Returning 404 for cross-tenant resources: reduces information leakage. Cost: harder support/debugging because "missing" and "forbidden" look the same externally.
- Centralized authz module/service: consistency across APIs and workers. Cost: latency and operational dependency if every request calls a remote authorizer.
- JWTs carrying tenant roles/permissions: fewer DB lookups. Cost: stale authorization until token refresh, larger headers, and painful revocation.
A good default in 2026 is still: authenticate with session/JWT, load current membership from your DB or cache, authorize in-process, and enforce tenant scope in SQL or Postgres RLS.
In practice
Example 1: Postgres row-level security for tenant scope
⚠️ Turning on RLS without creating matching policies can make existing queries return zero rows and break production traffic. Apply this first in staging, then enable it behind a feature flag or on one table at a time during a low-traffic window.
ALTER TABLE invoices ENABLE ROW LEVEL SECURITY;
ALTER TABLE invoices FORCE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation_invoices
ON invoices
USING (tenant_id = current_setting('app.tenant_id', true)::uuid)
WITH CHECK (tenant_id = current_setting('app.tenant_id', true)::uuid);
This forces every SELECT, UPDATE, and DELETE on invoices to see only rows for the current tenant, and prevents inserts/updates from writing another tenant's ID. Gotcha: your app must set app.tenant_id on every transaction or request-scoped connection; with pooled connections, forgetting to reset it leaks context between requests.
BEGIN;
SET LOCAL app.tenant_id = '0f6f2b9d-7a1f-4b6f-9db0-2a8a7f8d1111';
SELECT id, status FROM invoices WHERE id = 'inv_42';
COMMIT;
Use SET LOCAL inside a transaction so the setting disappears automatically at commit. The gotcha is connection pools in transaction mode vs session mode; test the exact behavior of your driver and pooler.
Example 2: Express middleware for permission + tenant membership
async function requirePermission(permission) {
return async function (req, res, next) {
const userId = req.auth?.sub;
const tenantId = req.header('X-Tenant-Id');
if (!userId) {
return res.status(401).json({ error: 'invalid_token' });
}
const membership = await db.query(
`SELECT m.role
FROM memberships m
WHERE m.user_id = $1 AND m.tenant_id = $2`,
[userId, tenantId]
);
if (membership.rowCount === 0) {
return res.status(403).json({ error: 'forbidden', tenant_id: tenantId });
}
const role = membership.rows[0].role;
const perms = await db.query(
`SELECT 1
FROM role_permissions
WHERE role = $1 AND permission = $2`,
[role, permission]
);
if (perms.rowCount === 0) {
return res.status(403).json({ error: 'forbidden', required_permission: permission });
}
req.authz = { userId, tenantId, role };
next();
};
}
app.post('/v1/invoices/:id/refund', await requirePermission('invoice.refund'), async (req, res) => {
const result = await db.query(
`UPDATE invoices
SET status = 'refunded', refunded_at = now()
WHERE id = $1 AND tenant_id = $2 AND status = 'paid'
RETURNING id, status`,
[req.params.id, req.authz.tenantId]
);
if (result.rowCount === 0) {
return res.status(404).json({ error: 'not_found' });
}
res.json(result.rows[0]);
});
This does the minimum viable thing correctly: membership check, permission check, then tenant-scoped write. Gotcha: this is two DB round trips before the business query; add a cache or precomputed permission map if latency matters, but do not drop the tenant predicate from the final UPDATE.
Example 3: diagnosing a missing tenant filter with logs and SQL row counts
curl -i -X GET \
-H "Authorization: Bearer eyJ..." \
-H "X-Tenant-Id: t_123" \
https://api.example.com/v1/invoices/inv_42
HTTP/1.1 200 OK
Content-Type: application/json
{"id":"inv_42","tenant_id":"t_999","amount":5000}
If you ever see a response body with a tenant different from the requested tenant, you have a data-layer bug, not just an authz bug. The immediate fix is to patch the query shape from:
SELECT id, tenant_id, amount FROM invoices WHERE id = $1;
to:
SELECT id, tenant_id, amount FROM invoices WHERE id = $1 AND tenant_id = $2;
Then add a regression test that creates the same invoice ID shape in two tenants and asserts tenant B cannot read tenant A's row. The gotcha is internal scripts and admin tools: they often use the old unsafe query long after the API is fixed.
Further reading
- PostgreSQL Documentation: Row Security Policies
- OWASP Authorization Cheat Sheet
- NIST SP 800-162 Guide to Attribute Based Access Control
- RFC 9110 HTTP Semantics
- Martin Fowler: Multi-Tenant Architecture
This article was written by an AI system and published pending human review. Verify anything you intend to act on.
Have a project in mind?
Get an instant AI price estimate for it, or talk directly to our team.
One email a month on what we learn building with AI