Multi-tenant RBAC means every permission check answers two questions at once: is this user a member of this tenant, and does their role in that tenant allow this action? If you're about to add roles to a SaaS that already has tenants, write the tenant-isolation rule first and layer roles on top of it. Hiding a button in the frontend is presentation, so the real decision has to sit where the data lives, where a hand-crafted API call can't walk around it.
This post is the access-control companion to the broader Multi-Tenant SaaS Architecture guide. There, the question is how tenants share infrastructure. Here, the question is who inside a tenant is allowed to do what. The code samples are illustrative patterns, not copies of any production rules file.
How does multi-tenant RBAC differ from single-tenant RBAC?
In a multi-tenant app, a role only has meaning inside one tenant, so the unit you authorize is a membership: a user, a tenant and a role together. A tenant admin is an admin in their own tenant and has no rights in anyone else's.
NIST describes the classic model in one sentence: each user is assigned one or more roles, and each role is assigned one or more privileges permitted to users in that role. Multi-tenancy adds a second axis on top. The consequence is that a role can't be a single column on the user record. One person can be an admin in one workspace and a read-only viewer in another, so the role lives on the membership row that links the two.
If you're still deciding how tenants are physically separated, read Database-per-Tenant vs Shared Schema for SaaS first, because that choice changes where your checks run.
Where should the access decision actually live?
It should live in the data layer, enforced by the database or its rules engine, because any check that lives only in the UI or a single middleware can be skipped. OWASP's 2025 Top 10 keeps Broken Access Control at number one and reports that 100% of the applications tested had some form of it.
That statistic is about the tested sample, not about every app in the wild, but the direction is clear. OWASP's prevention advice is blunt: except for public resources, deny by default, and enforce record ownership instead of letting users create, read, update or delete any record. The OWASP Authorization Cheat Sheet adds that permissions should be validated on every request, whether it came from an AJAX script, the server or anywhere else.
I apply that on Callidus, the UK aesthetic-clinic SaaS I built solo in React, TypeScript and Firebase. Per the case study, everything lives under tenants/{tenantId}/… in Firestore, the JWT claims carry tenantId and role, and every mutation passes an !isTenantArchived() guard in the Firestore rules. The product has six roles: super admin, owner, admin, manager, practitioner and receptionist. The code is built around role helpers (hasClinicalAccess, hasManagerAccess, hasAdminAccess) instead of inline role checks, so a change to the access matrix propagates consistently. The case study also lists 100% Firestore rules coverage. The subscription grace period shows why the rules layer matters: pages stay viewable while mutations are blocked at both the route and the Firestore rules layers, so no single layer can be bypassed on its own. A UI-only check would leave exactly that gap.
Callidus uses Firestore rules, not Postgres RLS. For the engine-level trade-offs, see Firestore Rules vs Postgres RLS for Multi-Tenant SaaS.
What does a tenant-plus-role rule look like?
Here's an illustrative Firestore rule where the path carries the tenant and a custom claim carries the role. Firebase documents that custom claims are read in Firestore rules through request.auth.token.
match /tenants/{tenantId}/records/{recordId} {
allow read: if request.auth.token.tenantId == tenantId;
allow write: if request.auth.token.tenantId == tenantId
&& request.auth.token.role in ['owner', 'admin'];
}
One Firestore-specific detail matters for your tests. Firebase states that rules are not filters: a query is evaluated against its potential result set, and if it could return a document the client may not read, the whole request fails. A cross-tenant query therefore errors with permission denied rather than returning an empty list, and your client queries must include the tenant constraint themselves.
How do you test that RBAC actually holds?
Test the negative paths, because the happy path passing tells you almost nothing. The Authorization Cheat Sheet recommends unit and integration tests for access control logic, including deny-by-default behaviour. Three checks belong in every suite:
- Cross-tenant read. Sign in as a user of tenant A and request tenant B's record. The request must be rejected (Firestore) or return no rows (Postgres RLS).
- Role escalation. Sign in as a viewer and attempt a write. The rule must reject it, not just the UI.
- Forged tenant. Send a
tenantIdin the request body that differs from the token's claim. The body value must be ignored.
If you only test that an admin can do admin things, you've tested the part that was never going to leak. Run the same three checks again whenever you add a role, because a new role is a new way to be wrong. SaaS Security and Compliance covers the wider threat surface around tenant data.
How do JWT claims carry the tenant and role?
At sign-in, the auth system puts the tenant and role into the signed token so the data layer can read them on every request without an extra lookup. The two common Firebase and Supabase routes differ in where the role comes from and how fast a change takes effect.
| Firebase custom claims | Supabase custom access token hook | |
|---|---|---|
| Where the role is set | Admin SDK writes claims onto the user | A hook runs before each token is issued and adds a claim such as user_role |
| Where it's read | request.auth.token in rules | auth.jwt() inside RLS policies or an authorize() function |
| Size limit | Payload must not exceed 1000 bytes | Not stated on the page I read |
| Role change takes effect | Next ID token issue: sign-in, expiry refresh, or forced refresh | Not described on the page I read |
Sources: Firebase custom claims and Supabase RBAC guide, both fetched 2026-09-30.
Two rules follow. First, claims are cached in the token, so a role change doesn't apply until a new token is issued. Firebase's docs say the client can force it with getIdToken(true), so call that after permission changes. Second, keep claims for access control only: Firebase warns that extra data in claims hurts performance because every authenticated request carries the token. Never trust a tenantId or role from the request body. For how the token is issued in the first place, see SaaS Authentication: Magic Links vs Passwords vs OAuth, and for a worked Auth.js setup, Auth.js v5 Multi-Tenant B2B SaaS.
How many roles should a new SaaS start with, and when is RBAC not enough?
Start with four coarse roles and split a permission only when a real customer workflow demands it. This is my recommendation, not a standard, and Callidus shows it isn't a ceiling: its six roles exist because clinical data, financial data, settings and billing each needed different access.
A workable starting ladder:
- Owner. Billing, deleting the workspace, transferring ownership. Usually exactly one.
- Admin. Manages members and most settings, but can't close the account.
- Member. Does the daily work: creates and edits records.
- Viewer. Read-only, for people who need visibility but shouldn't change anything.
A 20-permission matrix on day one feels thorough, but half of it never gets toggled and every entry is another branch your rules and tests must cover. When a paying customer says "I need someone who can edit bookings but not see revenue", split that permission out, because now you know what it's for.
RBAC also has limits. The OWASP cheat sheet says ABAC and ReBAC should typically be preferred for application development, because they express finer rules. NIST SP 800-162 defines ABAC as evaluating attributes of the subject, object, operation and environment against policy.
| Model | Decision is based on | Fits when |
|---|---|---|
| RBAC | The user's role | A handful of stable job functions |
| ABAC | Attributes of user, resource, action, environment | Rules like "only during business hours, only own location" |
| ReBAC | Relationships between users and resources | Sharing, ownership and hierarchies between objects |
What build order avoids rework?
- Model membership as its own record:
(userId, tenantId, role). Don't put the role on the user. - Write the tenant-isolation rule first and drive cross-tenant leakage to zero.
- Layer role checks on top, so every role check runs together with the tenant check.
- Put
tenantIdandroleinto the token as claims and refresh the token when permissions change. - Write the three negative tests above before you ship.
- Add fine-grained permissions only when a real workflow asks for one.
Roles are recoverable, leaked data isn't, so get the isolation boundary airtight first. Your next step: pick one write endpoint in your app today and try to call it as a viewer from another tenant. If it succeeds, you've found your first rule to write.
