Access control is one of those engineering problems that seems solved until it is not. An early-stage SaaS can get away with a simple admin/user toggle. By the time a product has dozens of features, multiple team tiers and enterprise customers with compliance requirements, that toggle has become a liability: too coarse to express real business rules, too brittle to audit and too expensive to change.
Role-based access control (RBAC) is the standard solution — but "RBAC" covers a wide spectrum from naïve boolean flags to a robust, auditable permission engine. This guide covers how to design RBAC that actually scales.
The Access Control Spectrum
Before RBAC, it helps to know what the alternatives are and why RBAC occupies the practical sweet spot for most SaaS products.
Access Control Lists (ACL): Permission is granted per user per resource. Simple to implement, impossible to manage at scale — changing what 500 users can do requires 500 updates.
Role-Based Access Control (RBAC): Users are assigned roles; roles carry permissions. Changing what a role can do changes it for every user with that role simultaneously. This is the right model for 90 % of SaaS applications.
Attribute-Based Access Control (ABAC): Policies evaluate dynamic attributes — user department, resource owner, request time, network location. More expressive but significantly harder to implement, reason about and audit. Worth the complexity only when RBAC cannot express the required policies.
Relationship-Based Access Control (ReBAC): Permissions derive from the graph of relationships between entities — the model behind Google Zanzibar and Airbnb's permission system. Powerful for social applications and fine-grained resource ownership; significant implementation investment.
For most SaaS: RBAC, with occasional ABAC extensions for specific policies.
Designing the Permission Model
The foundation of a good RBAC system is the permission model: what units of access you define and how you express them.
Module + Action
The most maintainable pattern is module:action. A module corresponds to a product feature area or resource type; an action is what you can do with it.
projects:read
projects:write
projects:manage
issues:read
issues:write
billing:manage
team:invite
reports:exportWhy this scales: You can add a new module (a new product feature) without touching any existing permission or role definition. You can add a new action to an existing module (projects:archive) without breaking anything. The permission set grows with the product.
Avoid: Boolean flags per feature (can_access_reports: true), action-less module strings (projects), or verb-object inconsistency (read_project, manage-billing). Inconsistency in naming leads to inconsistency in enforcement.
Action Hierarchy
For most modules, actions form a natural hierarchy: manage implies write implies read. Codify this:
const ACTION_HIERARCHY = {
manage: ['manage', 'write', 'read'],
write: ['write', 'read'],
read: ['read'],
};When checking whether a user can write, also check manage. This avoids the common bug where an admin who "manages" a resource cannot perform the write operations it contains.
Role Design
Roles are named bundles of permissions assigned to users. Good role design has three principles:
Principle of least privilege. A role should include only the permissions required to perform the intended function — no more. This limits blast radius if an account is compromised.
Role coherence. Every permission in a role should make sense in the context of the role's purpose. If a "member" role requires billing access to function, something is wrong with the permission model, not the role.
Avoid role explosion. If you find yourself creating roles like manager_with_billing_except_delete, your permission model is too coarse. Add more granular permissions rather than more roles.
A typical SaaS starting point:
| Role | Description | Key permissions |
|---|---|---|
owner | Workspace owner | Everything, including billing and team management |
admin | Administrative user | All operational permissions, no billing |
manager | Team lead / project owner | Create, write, manage within their scope |
member | Standard user | Read most things, write within assigned work |
viewer | Read-only stakeholder | Read access only |
billing_admin | Finance team member | Billing module only |
Multi-Role Users
In real organizations, people wear multiple hats. A developer might be member for most things but billing_admin for expense tracking. A contractor might be manager for one project and viewer for another.
Design for this from the start. The effective permission set is the union of permissions across all assigned roles. The enforcement logic stays the same; only the role assignment query changes.
// Effective permissions = union of all role permissions
const effectivePermissions = user.roles
.flatMap(role => role.permissions)
.map(p => p.name);
function hasPermission(user: User, permission: string): boolean {
return effectivePermissions.includes(permission);
}Enforcement: Where, How and How Often
Enforce at Every API Endpoint
Every route that performs a meaningful operation should check the relevant permission. The check should be:
- Consistent: Same permission check regardless of how the request arrives (REST, GraphQL, background job)
- Cheap: Permissions should be cached in the session/JWT after authentication; do not query the database on every request
- Fail-closed: If the permission check cannot be performed (missing role data, expired token), deny access — never default to allowing
A practical middleware pattern:
const requirePermission = (permission: string) =>
(req: Request, res: Response, next: NextFunction) => {
if (!req.user?.permissions.includes(permission)) {
throw new ApiError(403, `Permission required: ${permission}`);
}
next();
};
// At route definition:
router.post('/projects', requirePermission('projects:write'), createProject);
router.delete('/projects/:id', requirePermission('projects:manage'), deleteProject);Row-Level Enforcement
For resources owned by specific users or teams, add row-level checks. After verifying the user has projects:read, verify the project belongs to their organization (multi-tenant isolation) or is explicitly shared with them.
Separate these two checks conceptually:
- Permission check: Can this user perform this action class?
- Ownership/scope check: Does this user have access to this specific resource?
Conflating them leads to permission models that encode business logic — brittle and hard to maintain.
Keeping the UI Honest
Backend enforcement is non-negotiable, but a UI that shows every user every button — disabled with a tooltip — erodes trust and creates frustration. The UI should reflect real permissions.
Navigation: Hide menu items for modules the user cannot access. If they cannot access billing, the billing nav item should not appear.
Action buttons: Show only actions the user can perform. The "Delete" button should not be visible to a viewer.
Graceful edge cases: If a permission is checked asynchronously (after page load), show a skeleton rather than a flash of visible-then-hidden controls.
Maintain a permission store (client-side, loaded after auth) and derive UI state from it. Every component checks can('projects:write') rather than imperative if (user.role === 'admin') logic scattered through the codebase.
Audit Logging
For enterprise customers and compliance requirements, every permission check that results in a denial — and every permission-gated action that succeeds — should be logged.
Minimum log record:
- Timestamp
- User ID and role set at time of action
- Action performed (HTTP method + route, or semantic operation)
- Resource identifier
- Permission checked
- Outcome (allowed / denied)
This enables: security incident investigation, compliance audit trails, identification of users repeatedly attempting unauthorized actions, and validation that permission changes had the expected effect.
Testing RBAC
Access control is a security-critical feature that deserves its own test coverage.
For each permission: Write a test that verifies a user with the permission can perform the action, and a user without it cannot. These tests are fast, cheap and catch regressions immediately.
Role matrix tests: For each role and each endpoint category, verify the expected allow/deny outcome. This gives you a living specification of your access model.
Privilege escalation tests: Verify that no user can grant themselves permissions beyond their current role set — not through API parameters, not through request forgery.
Common Mistakes
Checking roles instead of permissions. if (user.role === 'admin') scattered through the codebase is unmaintainable. Check hasPermission('projects:manage') and let the permission model encode what admins can do.
Frontend-only enforcement. A hidden button is not access control. The API must enforce independently.
Caching stale permissions. If permissions are embedded in a JWT, rotation after role changes requires token invalidation. Design this upfront — either short token TTLs, a token denylist, or permissions fetched from cache with a short TTL.
No audit log. Without logs, you cannot investigate incidents, pass compliance audits or debug unexpected denials.
Conclusion
Good RBAC is invisible in normal operation: users see exactly what they need, sensitive actions stay protected and the system scales to hundreds of modules without requiring permission rewrites.
The investment is in getting the data model right — consistent module:action permissions, coherent roles, multi-role support from day one — and in enforcement discipline: every endpoint checked, UI derived from permissions, audit logging from the start.
Done right, RBAC becomes a competitive advantage with enterprise customers: it means your product can fit into their access control policies rather than requiring exceptions.
