Roles and permissions are the invisible skeleton of every SaaS product. When they work well, nobody notices. When they are wrong, users see data they should not, admins cannot manage their teams, and enterprise prospects walk away because you cannot support their compliance requirements.
We have designed and implemented access control systems for SaaS products ranging from early stage startups to platforms serving thousands of organizations. The pattern that works is not the one with the most flexibility. It is the one that matches your product's actual complexity level today while leaving room to grow.
Start With RBAC, Not ABAC
There are two main paradigms for access control: Role Based Access Control (RBAC) and Attribute Based Access Control (ABAC). RBAC assigns permissions to roles, then assigns roles to users. ABAC evaluates policies based on attributes of the user, the resource, and the environment.
ABAC sounds more powerful because it is. But that power comes with dramatically more complexity in implementation, testing, and debugging. For the vast majority of SaaS products, RBAC handles 95% of use cases cleanly. Start there.
The standard RBAC hierarchy for a SaaS product looks like this:
- Owner: Full control over the organization, including billing, deletion, and transferring ownership
- Admin: Can manage members, settings, and all resources, but cannot delete the organization or transfer ownership
- Member: Can create, read, update, and delete resources within the organization
- Viewer (optional): Read only access to resources
This four level hierarchy covers most SaaS products. Resist the urge to add more roles until you have real customer feedback demanding it. Every role you add multiplies the testing surface and the number of edge cases in your UI.
The Permission Model
Roles are just containers. The actual access control happens through permissions. A permission is a specific action on a specific resource type: `projects.create`, `invoices.read`, `members.delete`, `settings.update`.
Define permissions using a resource.action naming convention. This keeps things consistent and makes it easy to reason about what a permission grants. Common actions are: create, read, update, delete, manage (a superset that includes all CRUD plus administrative actions like archiving or exporting).
Store the role to permission mapping in a configuration file or a database table. Here is a simplified example:
- Owner: all permissions
- Admin: members.manage, settings.update, projects.manage, invoices.read, api_keys.manage
- Member: projects.create, projects.read, projects.update, projects.delete, invoices.read
- Viewer: projects.read, invoices.read
The permission check in your application code should be a single function call: `hasPermission(user, 'projects.delete', resourceId)`. This function checks the user's role in the current organization, resolves the permissions for that role, and returns a boolean.
Implementing Permission Checks
Permission enforcement must happen at three layers: the API layer, the database layer, and the UI layer.
API layer: Every API endpoint should verify that the requesting user has the required permission before executing any logic. This is typically implemented as middleware that runs before your route handler. If the user does not have permission, return a 403 Forbidden response. Never rely solely on the UI to hide actions, because any user can call your API directly.
Database layer: Use Row Level Security (RLS) policies in PostgreSQL to enforce data access at the database level. This is your safety net. Even if your application code has a bug that skips a permission check, the database will not return data the user should not see. This is especially important in multi tenant architectures where a single query bug could expose another organization's data.
UI layer: Hide or disable actions the user does not have permission to perform. This is not security; it is user experience. Users should not see a "Delete" button if they cannot delete. Fetch the user's permissions on login and use them to conditionally render UI elements.
Resource Level Permissions
Basic RBAC answers "can this user create projects?" but not "can this user edit this specific project?" For many SaaS products, the answer is the same for all resources of that type. But some products need resource level permissions where different users have different access to different instances of the same resource type.
The most common pattern is resource ownership plus sharing. The user who creates a resource is the owner, and they can share it with specific users or teams at specific permission levels (view, edit, manage).
This requires a sharing table:
- resource_shares: id, resource_type, resource_id, grantee_type (user or team), grantee_id, permission_level, granted_by, created_at
Your permission check function now has an additional step: first check the user's organization role for the base permission, then check the resource_shares table for resource specific overrides.
Keep resource level permissions optional. Most SaaS products do not need them at launch. Add them when a customer is willing to pay for it.
Custom Roles
Enterprise customers will inevitably ask for custom roles. "We need a role that can create projects and view invoices but cannot manage members or see billing." This is a reasonable request, and your architecture should support it without a rewrite.
The cleanest implementation is to allow organization admins to create custom roles by selecting from the available permissions. Store custom roles in the database, scoped to the organization:
- custom_roles: id, org_id, name, description, permissions (array of permission strings), created_at
When checking permissions, resolve the user's role (which might be a custom role), look up its permissions (from config for built in roles, from the database for custom roles), and check against the required permission.
Do not ship custom roles on day one. Ship the standard four role hierarchy, and add custom roles when you are selling to enterprises that need them. Premature flexibility here creates a configuration surface that confuses smaller customers.
The Permission Check Function
Your entire permission system funnels through one function. Here is the logic it should follow:
1. Resolve the user's organization membership and role
2. If the role is Owner, return true (owners can do everything)
3. Look up the permissions for the user's role
4. Check if the required permission is in the list
5. If the resource has resource level permissions, check those as an override
6. Return the result
Cache aggressively. Permission checks happen on every API request, often multiple times per request. Cache the user's role and permissions in memory or in a fast store like Redis. Invalidate the cache when roles or memberships change.
Implementing in Your Stack
For the web application, the frontend should receive the user's permissions as part of the authentication payload or session. A common approach is including permissions in the JWT token or fetching them from a dedicated permissions endpoint on login.
Create a utility function or hook (in React, a `usePermission` hook) that components use to check access:
```
// Pseudocode
const canDeleteProject = usePermission('projects.delete')
if (!canDeleteProject) return null // hide the button
```
On the backend, create middleware that extracts the required permission from the route definition and checks it before the handler executes. This keeps permission logic out of your business logic and ensures it cannot be accidentally skipped.
Audit Logging
Every permission sensitive action should be logged. Who did what, to which resource, at what time, and whether it was allowed or denied. This serves three purposes:
1. Security: Detect unauthorized access attempts
2. Compliance: Enterprise customers need audit trails for SOC 2, HIPAA, and similar certifications
3. Debugging: When a user reports they cannot access something, the audit log tells you exactly what permission check failed and why
Store audit logs in an append only table or a dedicated logging service. Never delete or modify audit log entries. Review our web application security checklist for additional security considerations around access control.
Common Mistakes
Checking roles instead of permissions. Never write `if (user.role === 'admin')` in your application code. Always check permissions. Roles are just a way to group permissions. When you add custom roles or change what a built in role can do, permission checks keep working. Role checks break.
Not handling the "no organization" state. Users who have just signed up but have not created or joined an organization are in a liminal state. Your permission system needs to handle this gracefully, typically by redirecting them to an organization creation or join flow.
Forgetting to check on the write path. Many teams check permissions on read (can the user see this page?) but forget to check on write (can the user submit this form?). Both paths need enforcement.
Over engineering from the start. If you are comparing custom development approaches, remember that the simplest system that meets your current needs is the right choice. You can always add complexity later. You cannot easily remove it.
A well designed permission system is invisible to users and empowering for administrators. It protects data, enables collaboration, and unlocks the enterprise sales conversations that drive serious revenue. If you need help designing roles and permissions for your SaaS product, get in touch with our team.