Founder & Lead Engineer, RAITHub
Design SaaS authorization as three checks on the server for every request: which tenant the user is acting in, which permissions their role in that tenant grants, and whether they may touch this specific record. Store permissions as named codes, group them into roles per tenant, enforce conflicting pairs in the database, and put row-level security underneath as a second wall. Then test every role.
Authorization is the "may you do this?" question that follows authentication's "who are you?". In a multi-tenant product it is also where the worst bugs live: the OWASP Top 10:2025 keeps broken access control in first place and reports that 100% of the applications tested had some form of it (OWASP A01:2025). The multi-tenant SaaS guide gives the overview; this post is the design in detail, with SQL and TypeScript, using Sundor Skin, a B2B wholesale platform RAITHub built with 88 permission codes across 12 staff roles, as the worked example.
What are the layers of authorization in a SaaS product?
Five layers, each answering a different question. Most access-control bugs are one missing layer, not a wrong rule.
| Layer | Question it answers | Where it is enforced | What goes wrong without it |
|---|---|---|---|
| Tenant scope | Which organisation is this request for, and is the user a member? | Session and membership lookup | A user in two organisations acts in the wrong one; a tenant ID in the URL is trusted |
| Permission | Does the user's role in this tenant allow this action? | A server-side check before every handler | A hidden button is the only thing stopping a junior role |
| Resource | May this user touch this particular record? | Ownership or attribute check in the query | IDOR: change an ID, read someone else's record |
| Database backstop | Even if the code forgets, can the query return another tenant's rows? | PostgreSQL row-level security | One missing WHERE clause leaks every tenant |
| Audit | Who did what, and who granted them the right to? | Audit log written in the same transaction | You cannot answer the customer after an incident |
Should a SaaS use RBAC, ABAC or ReBAC?
Use roles built from permissions for what a user may do, and attribute or relationship checks for which records they may do it to. Pure role checks cannot express "only your own orders", and pure relationship graphs are more machinery than most B2B products need at launch.
Role-based access control (RBAC) assigns users to roles and gives each role permissions; NIST's model became the standard ANSI/INCITS 359 (NIST RBAC project). Attribute-based access control (ABAC) decides from properties of the user, record and context, such as "the order's buyer is this user" or "the amount is under the approval limit". Relationship-based access control (ReBAC) decides from links in a graph, such as "shared with this team". The OWASP Authorization Cheat Sheet advises to "Prefer Attribute and Relationship Based Access Control over RBAC", because they handle fine-grained rules and scale better as rules grow.
| Model | Good at | Weak at | Use it for |
|---|---|---|---|
| RBAC with permission codes | Clear, auditable answers to "what can this role do?" | Per-record rules; role explosion if every exception becomes a role | Actions: approve an order, edit prices, invite users |
| ABAC | Ownership, limits, status and time rules | Harder to answer "who can do X?" for a reviewer | Own records only; approve up to a credit limit; locked after dispatch |
| ReBAC | Sharing, folders, nested teams | Needs a relationship store and its own tooling | Document-sharing products; later, not first |
That combination follows the OWASP advice in spirit: permissions keep the "who can do what" answer auditable, and the attribute checks carry the fine-grained rules.
How should you model roles and permissions in the database?
As four tables: a fixed catalogue of permission codes that you own, roles that belong to a tenant, the permissions in each role, and memberships linking a user to a role in a tenant. Add a fifth table for pairs of permissions no role may hold together. The schema below is a simplified version of the pattern; Sundor Skin's production schema differs in detail.
CREATE TABLE permissions (
code text PRIMARY KEY, -- e.g. 'order.approve'
description text NOT NULL
);
CREATE TABLE roles (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id uuid REFERENCES tenants (id), -- NULL = built-in role you ship
name text NOT NULL,
UNIQUE NULLS NOT DISTINCT (tenant_id, name) -- PostgreSQL 15+
);
CREATE TABLE role_permissions (
role_id uuid REFERENCES roles (id) ON DELETE CASCADE,
permission_code text REFERENCES permissions (code),
PRIMARY KEY (role_id, permission_code)
);
CREATE TABLE memberships (
user_id uuid REFERENCES users (id),
tenant_id uuid REFERENCES tenants (id),
role_id uuid NOT NULL REFERENCES roles (id),
PRIMARY KEY (user_id, tenant_id)
);
-- Separation of duties: pairs no single role may hold.
CREATE TABLE permission_conflicts (
a text REFERENCES permissions (code),
b text REFERENCES permissions (code),
PRIMARY KEY (a, b),
CHECK (a < b)
);
A membership should also check that a tenant's custom role is used only in that tenant; a trigger or a composite foreign key on (role_id, tenant_id) does it.
Enforcing separation of duties in the database
Separation of duties means no one person can complete a sensitive action alone: whoever raises a credit note should not also approve it. Checking this in the admin screen is not enough, because a script or a future screen can skip it. A trigger makes the database refuse:
CREATE FUNCTION reject_conflicting_permission() RETURNS trigger
LANGUAGE plpgsql AS $$
BEGIN
-- Lock the role so two concurrent grants cannot both pass the check.
PERFORM 1 FROM roles WHERE id = NEW.role_id FOR UPDATE;
IF EXISTS (
SELECT 1
FROM role_permissions rp
JOIN permission_conflicts c
ON (c.a = NEW.permission_code AND c.b = rp.permission_code)
OR (c.b = NEW.permission_code AND c.a = rp.permission_code)
WHERE rp.role_id = NEW.role_id
) THEN
RAISE EXCEPTION 'role % cannot hold % with a conflicting permission',
NEW.role_id, NEW.permission_code;
END IF;
RETURN NEW;
END $$;
CREATE TRIGGER role_permissions_no_conflict
BEFORE INSERT OR UPDATE ON role_permissions
FOR EACH ROW EXECUTE FUNCTION reject_conflicting_permission();
On Sundor Skin, conflicting permission pairs are enforced in the database in the same spirit, so no single staff role can hold both sides of a sensitive pair. If your product lets one user hold several roles in a tenant, run the same check across the union of their roles.
How many permissions and roles does a SaaS need?
As many permissions as there are distinct sensitive actions, and as few roles as your customers' job titles. Sundor Skin, a wholesale platform with buyer approval, tier pricing, credit limits and batch stock, needs 88 permission codes grouped into 12 staff roles. A simpler SaaS might start with 20 to 30 codes and 3 or 4 roles.
- Name codes
resource.action, such asorder.approveorcredit_limit.edit. They read well in code, in the audit log and in a security questionnaire answer. - Check permissions in code, never role names.
if (role === 'manager')breaks the day a customer creates a role called "Team lead". Roles are data; permissions are the contract. - Keep the catalogue in code and the database in step, so a typo is a compile error:
export const PERMISSIONS = [
'order.view',
'order.approve',
'credit_limit.edit',
'price_override.edit',
'user.invite',
] as const
export type PermissionCode = (typeof PERMISSIONS)[number]
A migration inserts any new codes, and a test fails if the code list and the permissions table differ. The example codes are illustrative, not Sundor Skin's.
How do you check permissions on every request?
In one server-side function that every handler calls before doing anything, which denies by default and takes the tenant from the verified session, never from the request. OWASP's cheat sheet lists both principles by name: "Deny by Default" and "Validate the Permissions on Every Request".
import type { PoolClient } from 'pg'
import type { PermissionCode } from './permissions'
export class Forbidden extends Error {}
export async function requirePermission(
db: PoolClient,
session: { userId: string; tenantId: string },
code: PermissionCode,
): Promise<void> {
const { rowCount } = await db.query(
'SELECT 1 FROM memberships m ' +
'JOIN role_permissions rp ON rp.role_id = m.role_id ' +
'WHERE m.user_id = $1 AND m.tenant_id = $2 AND rp.permission_code = $3',
[session.userId, session.tenantId, code],
)
if (!rowCount) throw new Forbidden(code) // no membership or no permission: deny
}
// In a handler: action check first, then a record check scoped to the tenant and user.
await requirePermission(db, session, 'order.approve')
const order = await db.query(
'SELECT id FROM orders WHERE id = $1 AND tenant_id = $2',
[orderId, session.tenantId],
)
if (order.rowCount === 0) return notFound() // 404, not 403: do not confirm it exists
Three details matter. Return 404 for records in another tenant, so an attacker cannot confirm which IDs exist. Read permissions fresh per request, or cache them for seconds, not for the life of a token: a permission list baked into a long-lived JWT keeps working after you revoke it. And log every denial with the permission code, so a spike of Forbidden errors is visible.
Where does row-level security fit with RBAC?
Underneath it. Row-level security (RLS) makes PostgreSQL filter every query by a policy, so a handler that forgets the tenant condition still cannot return another tenant's rows. Use a restrictive policy for the tenant boundary and permissive ones for rules within a tenant:
-- Every row must belong to the current tenant. RESTRICTIVE policies are
-- AND-ed with all others, so no later policy can widen past the tenant.
CREATE POLICY tenant_scope ON orders AS RESTRICTIVE
USING (tenant_id = NULLIF(current_setting('app.tenant_id', true), '')::uuid)
WITH CHECK (tenant_id = NULLIF(current_setting('app.tenant_id', true), '')::uuid);
-- Within the tenant: buyers see their own orders; staff see all of them.
CREATE POLICY own_or_staff ON orders
USING (
buyer_id = NULLIF(current_setting('app.user_id', true), '')::uuid
OR current_setting('app.is_staff', true) = 'true'
);
A table with only restrictive policies returns nothing, because at least one permissive policy must allow a row. The setup, the connection-pool rules and the tests are in the Postgres row-level security guide. On Sundor Skin, every buyer query runs under row-level security scoped to that buyer, and CI fails the build if a buyer-scoped table lacks a policy.
How do customer-defined roles, SSO groups and support access work?
All three are ways of granting permissions, so each needs a rule that stops it granting too much.
- Customer-defined roles. Customers pick from your permission catalogue; they never invent codes. A tenant admin may only grant permissions they hold themselves, or an admin can create a role more powerful than their own.
- SSO and SCIM groups. Map identity-provider groups to roles in a table the tenant admin controls, and re-evaluate the mapping at each sign-in, so removing someone from a group in their directory removes the access.
- Your own support staff. Access to a customer's tenant should be time-limited, tied to a ticket, written to the customer-visible audit log, and never a permanent super-admin flag.
How do you test SaaS authorization?
With a permission matrix that calls every endpoint as every role, an IDOR suite that attacks records across tenants, and tests for the separation-of-duties rules. OWASP recommends exactly this: "Create Unit and Integration Test Cases for Authorization Logic".
import { it, expect } from 'vitest'
const matrix: Array<[role: string, method: string, path: string, status: number]> = [
['viewer', 'POST', '/api/orders/o1/approve', 403],
['approver', 'POST', '/api/orders/o1/approve', 200],
['approver', 'PATCH', '/api/buyers/b1/credit-limit', 403],
['finance', 'PATCH', '/api/buyers/b1/credit-limit', 200],
]
it.each(matrix)('%s: %s %s returns %i', async (role, method, path, status) => {
const res = await as(role).request(method, path) // your authenticated test client
expect(res.status).toBe(status)
})
Generate the matrix from the route list, so a new endpoint without an entry fails the build. Sundor Skin runs a 21-case IDOR suite that tries to read other buyers' data, among 530+ automated tests; PropDesk serves 4 roles (landlord, tenant, contractor, admin) from one codebase with 1,024 automated tests. If you suspect a boundary is already broken, start with users can see another tenant's data. How to present all of this to a buyer's security reviewer is in SaaS security best practices.
Why RAITHub for SaaS authorization
Because RAITHub has designed permission systems at this size and tests them the way an attacker would.
- Permission-based roles in production. Sundor Skin: 88 permission codes, 12 staff roles, conflicting pairs refused by the database, and a hash-chained audit log written in the same transaction as each change.
- Two walls, both tested. Server-side permission checks plus row-level security, with a CI check for tables missing a policy and an IDOR suite.
- Designed before code. The permission list, starting roles and conflicting pairs go into the architecture spec you sign; see the SaaS development service.
- Retrofits too. Replacing "is admin" flags in a live product with permission codes and a test matrix is a common QA and test automation or backend engagement.
When you don't need us
- One role, one tenant. An internal tool where every user may do everything needs authentication, not an authorization model.
- Your framework's policy library already covers it. If your stack has a mature authorization library and your rules are simple, use it, and add the test matrix above.
- You need document-level sharing across thousands of objects. A dedicated relationship-based authorization service may suit you better than tables you maintain yourself.
To design or review your permission model, book the free technical audit and bring your current role list.
Written 29 September 2026. Sources checked on 29 September 2026.
Frequently asked questions
What is the difference between roles and permissions?
A permission is one named action, such as order.approve. A role is a named set of permissions given to users, such as Approver. Code should check permissions, so roles can change without code changes.
Should authorization be checked in the frontend or the backend?
The backend, on every request. The frontend may hide buttons for a better experience, but OWASP is clear that server-side enforcement is what counts; anyone can call your API directly.
How do I stop users in one tenant accessing another tenant's data?
Take the tenant from the verified session and membership, scope every query to it, return 404 for other tenants' records, and add PostgreSQL row-level security as a database backstop. Test it with an IDOR suite on every build.
How many roles should a SaaS start with?
Usually three or four, matched to your customers' job titles, built from 20 to 30 permission codes. Sundor Skin, a complex B2B platform, uses 12 staff roles from 88 codes.
What is separation of duties in SaaS?
A rule that one person cannot complete a sensitive action alone, such as raising and approving the same credit note. Enforce it by listing conflicting permission pairs and having the database refuse any role that holds both.
Can I put permissions in a JWT?
You can, but a revoked permission keeps working until the token expires. Keep tokens short-lived, or look permissions up per request with a cache measured in seconds.
Related posts
Ready to discuss your project?
Book a free 15-minute technical audit with our engineering team.