Role-based access control (RBAC) for a multi-tenant SaaS works by attaching every user to a tenant, giving them one or more roles within that tenant, and then enforcing both the tenant boundary and the role's permissions at the data layer — not just in the UI. The reliable pattern is to put the tenantId and role into the auth token (a signed JWT claim), then write database rules that read those claims on every query. Hiding a button in the frontend is presentation; the actual access decision has to happen where the data lives, so a crafted API call can't slip past 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.
Key takeaways
- RBAC has two jobs in a multi-tenant app: keep tenants apart (isolation) and keep roles apart inside a tenant (authorization). Treat them as one combined check, not two separate features.
- Put
tenantIdandrolein the JWT as custom claims so the data layer can read them on every request without an extra lookup. - Enforce permissions at the database layer (Firestore security rules or Postgres RLS), because the UI can be bypassed and any API call can be forged.
- Start with 3-4 coarse roles (owner, admin, member, viewer). Add fine-grained permissions only when a real customer asks — premature permission matrices rot fast.
- Always test the negative path: confirm a user from tenant A genuinely cannot read tenant B's rows, and that a viewer cannot write.
What is RBAC and how does it differ in a multi-tenant app?
In a single-tenant app, RBAC is simple: a user has a role, the role grants permissions, done. Multi-tenancy adds a second axis. Now every permission check has to answer two questions at once: "Is this user even in this tenant?" and "Does this user's role allow this action?" Both have to pass. A tenant admin is an admin only within their own tenant — they have zero rights in anyone else's.
The cleanest mental model is a triple: (user, tenant, role). A single user can hold different roles across tenants (an agency operator might be admin in one workspace and viewer in another). That's why the role can't live as a single column on the user record — it lives on the membership, the link between a user and a tenant.
If you're still deciding how tenants are physically separated in the database, read Database-per-Tenant vs Shared Schema for SaaS first, because that choice changes where your RBAC checks run.
Where should the access decision actually live?
The single most common mistake is enforcing access only in the frontend — hiding the "Delete" button from viewers and calling it done. That's not access control; it's a hint. Anyone can open the network tab, copy the request, change a value, and replay it. The access decision must live at the data layer, where there's no UI to bypass.
On Callidus, a clinic SaaS I built solo in React, TypeScript and Firebase over about ten weeks (mid-February to late April 2026), the data layer is Firestore. The enforcement model is per-tenant Firestore security rules that read a tenantId claim baked into each user's JWT. A query for clinic records only returns rows whose tenantId matches the caller's claim — so even a hand-crafted request from a logged-in user of a different clinic returns nothing. The role claim sits alongside it: a viewer role passes the read rule but fails the write rule.
Callidus actually started as a FlutterFlow attempt that collapsed under roughly 200 errors before I rebuilt it from scratch. One lesson from that rebuild: when the access model is fuzzy, low-code tools let you ship a UI that looks finished while the data layer is wide open. Rewriting in React with explicit Firestore rules made the boundary something I could read and reason about line by line.
For a deeper comparison of the two dominant enforcement engines, see Firestore Rules vs Postgres RLS for Multi-Tenant SaaS.
How do JWT claims carry the tenant and role?
The trick that makes data-layer RBAC fast is putting the tenant and role into the auth token itself. When a user signs in, the backend mints (or in Firebase's case, sets via custom claims) a JWT containing something like { tenantId: "clinic_42", role: "admin" }. Every subsequent request carries that token, the data layer reads the claim directly, and there's no extra database round-trip just to find out who's asking.
A Firestore rule reading that claim looks roughly like:
allow read: if request.auth.token.tenantId == resource.data.tenantId;
allow write: if request.auth.token.tenantId == resource.data.tenantId
&& request.auth.token.role in ['owner', 'admin'];
The equivalent in Postgres RLS reads the claim from the session and compares it in a USING clause on the policy. Either way, the principle is identical: the claim is the source of truth, and it's signed, so the client can't tamper with it.
Two things to get right. First, claims are set at login and cached in the token, so a role change doesn't take effect until the token refreshes — force a refresh on permission changes, or keep token lifetimes short. Second, never trust a tenantId or role that arrives in the request body; only trust the one in the verified token. For how the token gets issued in the first place, SaaS Authentication in 2026: Magic Links vs Passwords vs OAuth covers the sign-in side that feeds these claims.
How many roles should a new SaaS start with?
Fewer than you think. Most B2B SaaS apps run comfortably on four roles:
- Owner — billing, deleting the workspace, transferring ownership. Usually exactly one.
- Admin — manages members and most settings, but can't kill the account.
- Member — does the actual day-to-day work: creates and edits records.
- Viewer — read-only, for stakeholders who need visibility but shouldn't change anything.
This ladder covers the overwhelming majority of real customer needs. The temptation is to design a 20-permission matrix on day one because it feels thorough. In practice those matrices go stale: half the permissions are never toggled, and each one is another branch your rules and tests have to cover. Add granularity reactively — when a paying customer says "I need a role that can edit bookings but not see revenue," that's the signal to split a permission out, and now you know exactly what it's for.
This is the same restraint that keeps the rest of a SaaS lean. On BookBed, a booking platform I built solo in Flutter and Firebase that runs from one codebase across six OS platforms (iOS, Android, Web, macOS, Linux, Windows), the access model stayed deliberately small — a property owner manages their own units and bookings, with bidirectional iCal sync keeping external calendars aligned. At €9/mo for up to 20 units, the buyer is a small operator, not an enterprise org chart, so a heavy permission system would have been weight with no payoff.
How do you test that RBAC actually holds?
RBAC is one of the few features where the happy path passing tells you almost nothing. What matters is the negative path. Three checks belong in every multi-tenant RBAC test suite:
- Cross-tenant read — log in as a user of tenant A, request a record you know belongs to tenant B, and confirm it returns nothing (not an error you can probe, just empty).
- Role escalation — log in as a viewer, attempt a write, confirm it's rejected by the rule, not just hidden in the UI.
- Forged claim — send a request with a
tenantIdin the body that differs from the token's claim, and confirm the body value is ignored entirely.
If you only test that an admin can do admin things, you've tested the part that was never going to leak. Security and isolation deserve their own dedicated coverage — SaaS Security and Compliance goes deeper on the broader threat surface around tenant data.
A practical build order
If you're standing up RBAC on a new multi-tenant SaaS, this order avoids the most rework:
- Model membership as its own record:
(userId, tenantId, role). Don't put role on the user. - Write the tenant-isolation rule first, before any role logic. Get cross-tenant leakage to zero.
- Layer roles on top of the isolation rule — every role check runs and the tenant check.
- Put
tenantIdandroleinto the token as claims; refresh the token on changes. - Write the three negative-path tests above before you ship.
- Add fine-grained permissions only when a real customer's workflow demands one.
Get the isolation boundary airtight first and the role layer becomes the easy part. The expensive failures are always the ones where tenant A could see tenant B — roles are recoverable, leaked data is not.
