Identity and Authorization in SaaS: Single Sign-On, Roles, and Tenant Boundaries
In a multi-tenant SaaS product, authentication, role, and tenant boundary are three separate axes. How to pick an identity model, how to match tenants during OIDC federation, how to choose between RBAC, ABAC, and ReBAC, and why the tenant boundary should not live in the application layer alone.
Authentication and authorization are not the same problem
In a multi-tenant SaaS product, the two concepts that get confused most often are authentication (who is this user, really) and authorization (what can this user do, on which resource, inside which tenant). Once authentication works, it feels like the hard part is done, because the user is logged in and the interface behaves. The real risk lives on a third axis that is easy to forget: the tenant boundary. Even if a user's role is correctly assigned, the system is not safe if that role also grants access to another tenant's data. These three axes, identity, role, and tenant, need to be verified independently but together. Solving one and treating the other two as assumptions is the most common source of SaaS security bugs.
Identity: your own user table or an external provider
At small scale, running your own user table (email, password hash, session) is a reasonable starting point. As the product grows, especially once you start closing enterprise customers, you will get requests to federate with the customer's own identity provider (Okta, Azure AD, Google Workspace). There are two paths here.
Keeping your own identity management and adding OIDC/SAML federation on top is the right choice for most mid-sized SaaS products. You keep your user table; the external provider only supplies the "this person is verified" signal.
Delegating identity entirely to an external identity server (Auth0, Keycloak, Zitadel) saves time once you have many enterprise customers with complex federation requirements. The cost is that every custom behavior in your identity flows now has to fit inside the provider's flexibility.
Whichever path you take, what the identity provider returns only answers "who." The answers to "which tenant" and "what can they do in that tenant" belong in your own system, outside the provider. Storing role and tenant information inside the identity provider locks you in the moment you need to switch providers or support more than one.
Matching tenants during single sign-on
When federating with OIDC, the most consequential decision is how you determine which tenant an incoming user belongs to. Three patterns are common.
A separate OIDC client per tenant: each tenant gets its own client_id and redirect URI, so the tenant is known before the login flow even starts. This is the cleanest option if your number of enterprise customers is bounded.
A single client with email domain matching: after login, you look at the email domain to decide which tenant the user belongs to. This scales easily, but has a trap: two different organizations sharing the same email domain (for example, clients of the same consulting firm) can end up mapped to the same tenant by mistake.
Invitation-based onboarding: a user is invited into a specific tenant first, and authentication rides on top of that invitation. Tenant assignment is never left to automatic domain matching. This is the most robust option from a security standpoint, at the cost of one extra step in the user flow.
With just-in-time provisioning enabled, assigning a tenant based on an unverified email domain is a real leak. If a user can claim any domain on their own mail provider, they can land in the wrong tenant. If JIT provisioning is on, tenant mapping must rely on a verified domain list or an explicit invitation record, not a bare string match.
Choosing an authorization model: RBAC, ABAC, ReBAC
Role-based access control (RBAC) works on "this user is an editor, editors can do X." It is enough when the number of roles is small and permissions don't depend on the specific resource. It is also the easiest to implement and audit, and the right starting point for most SaaS products.
Attribute-based access control (ABAC) bases the decision not just on role but on attributes of the resource and the request: "an editor can only edit documents belonging to their own department." This is what you reach for once RBAC's permission count explodes because you keep adding qualifiers like department, project, or region.
Relationship-based access control (ReBAC) defines permission through a graph of relationships between objects: "this user can edit this document because they are a member of this folder, and this folder belongs to this project." Popularized after Google's Zanzibar paper, this model earns its complexity in products with nested sharing hierarchies (folders, subfolders, shared links) where neither RBAC nor ABAC holds up cleanly.
Trying to build all three at once is unnecessary complexity. If your permission model can be described as "who has which role," RBAC is enough. If you need "who has which role, for which resource," move to ABAC. If sharing chains nest inside each other, consider ReBAC. Skipping straight to ReBAC means maintaining a graph query layer that most products never actually need.
Where to enforce the tenant boundary
Enforcing the authorization decision in application code (middleware, a check before the controller) is common and necessary, but fragile as the only layer: one developer forgetting a single WHERE tenant_id = ? clause exposes every tenant's data to anyone who asks. This class of bug, insecure direct object reference, is the most common data leak in multi-tenant systems.
This is why enforcing the tenant boundary at the database layer, in addition to the application layer, gives you real defense in depth. PostgreSQL's row-level security exists for exactly this:
ALTER TABLE documents ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON documents
USING (tenant_id = current_setting('app.current_tenant')::uuid);
The application runs SET app.current_tenant = '...' on the connection at the start of each request; after that, no query running on that connection can see another tenant's rows, even if a developer forgets the WHERE clause. Schema-per-tenant (a separate PostgreSQL schema for each tenant) achieves the same isolation at a different cost: the leak risk is lower, but migrations and connection pool management get heavier as the schema count grows. If you have a small number of large tenants, schema-per-tenant tends to be more maintainable; if you have many small tenants, a shared schema with row-level security usually is.
Traps
Embedding permissions inside a JWT and caching them means a role change has no effect until the token expires. If access needs to be revoked immediately (termination, a security incident), you need either a fresh check on every request or short-lived tokens plus a server-side revocation list, not permissions baked into a long-lived token.
Letting a super admin or support role bypass the tenant boundary is usually written as a separate code path, and that path is often the one that never gets audited. If support needs to impersonate a customer account, that action should generate its own audit record and expire on its own; an unbounded "admin sees everything" bypass means that the day the support interface itself gets compromised, every tenant is exposed at once.
Checking a role only in the interface (hiding a button) without repeating the check in the API lets any client that skips the interface, a direct API call, browser developer tools, perform the action anyway. Authorization decisions must be re-verified server-side at every endpoint that receives the request, not just where the button lives.
Combining JIT provisioning with email domain matching while skipping domain verification leaves the same wrong-tenant leak described above wide open.
Scattering role hierarchy checks through the codebase as ad hoc conditions like if role == 'admin' or role == 'owner' means every new role requires scanning the entire codebase. Routing permission checks through a single central point, an authorization library or service, reduces adding a new role to a change in one place.
When you don't need any of this
For a single-tenant product, or one with very few tenants that are all trusted internal teams (an internal tool a company builds for its own departments), layers like row-level security or ReBAC add complexity you don't need. Simple role checks plus one consistent query helper in the application layer, an ORM scope that automatically adds tenant_id to every query, is usually enough. Add the heavier machinery once you actually become multi-tenant and a real trust boundary exists between tenants; building the heaviest model from day one means maintaining an abstraction that never gets used.