Which authorization strategy is best for your app?
A practical framework for choosing between ACL, RBAC, ABAC, ReBAC, and FGA based on the access patterns your product actually needs.
.webp)
Authorization is the kind of problem that sneaks up on you. You ship v1 with every user seeing the same thing, and it works fine until your first enterprise prospect asks "can we restrict who can delete projects?" Then an AI agent summarizes a confidential document into a shared channel, and suddenly authorization isn't just about what humans can do. It's about what anything acting inside your system can do, and on whose behalf. The choices you make now will echo through your codebase for years.
The problem is that "authorization" isn't one thing. It's a spectrum of strategies, each with different tradeoffs around complexity, flexibility, and how well they hold up as your app grows. Picking the wrong one doesn't just mean refactoring later. It means fighting your own abstractions every time a customer asks for something your model can't express.
This guide breaks down the four main authorization strategies, when each one shines, and how to figure out which one is right for your app.
The four strategies at a glance
Before diving deep, here's the landscape:
- ACL (Access Control Lists): Per-resource lists of who can do what. Think file system permissions.
- RBAC (Role-Based Access Control): Users get roles, roles get permissions. The workhorse of SaaS authorization.
- ABAC (Attribute-Based Access Control): Policies evaluate attributes of the user, resource, and environment at request time. Maximum flexibility, maximum complexity.
- ReBAC (Relationship-Based Access Control): Permissions derived from the graph of relationships between users and resources. The model behind Google Drive's sharing, and the most common way to implement Fine-Grained Authorization (FGA).
Each of these isn't just a different way to store permissions they represent fundamentally different ways of thinking about who should have access to what and why.
ACLs: Simple, direct, and surprisingly limited
An Access Control List is exactly what it sounds like: every resource maintains a list of users (or groups) and what actions they're allowed to perform. It's the most intuitive model. You look at a document, you see who has access, you add or remove people. Done.
This is the model you're already familiar with. Unix file permissions (rwxr-xr--) are ACLs. Network firewalls use ACLs. The "Share" button in Google Docs is, at its core, a user-friendly ACL interface.
At the schema level, an ACL is just a join table between resources and identities:
That's it. No indirection, no graph traversal. The simplicity is genuine, but it's also the source of every problem you'll hit later.
When ACLs work well
ACLs are a natural fit when your app's primary access pattern is "specific users need access to specific resources." If you're building something where the owner of a resource explicitly decides who else can see or edit it, an ACL is the simplest model that works.
Think: a project management tool where project creators invite collaborators, or a file storage app where users share folders with teammates.
When ACLs break down
The problem is that ACLs don't abstract well. Every resource has its own independent list, and there's no higher-level concept tying them together. This creates two concrete problems.
Onboarding becomes a manual chore. When a new engineer joins the team, someone has to grant them access to every relevant project, document, and environment individually. There's no way to say "give them everything an engineer should have." You're updating dozens of individual lists. In practice, this means new hires spend their first week pinging people on Slack asking for access to things, and six months later they're still discovering resources they should have been able to see from day one.
Auditing is nearly impossible at scale. If your security team asks "what can user X access?" you have to scan every resource's ACL in the entire system. There's no central index. Running that query across a million documents in a production database is the kind of thing that gets you paged at 2am because it locked up the table.
ACLs start simple but tend to become a maintenance nightmare once you have more than a handful of resources and users. Most apps that start with ACLs end up bolting on something role-like on top within a year or two.
RBAC: The right default for most SaaS apps
Role-based access control is the most widely adopted authorization model in SaaS, and for good reason. Instead of assigning permissions to individual users, you define roles (like admin, editor, viewer) and assign permissions to those roles. Users are then assigned one or more roles, and they inherit the corresponding permissions.
This simple indirection (user → role → permissions) solves most of the problems ACLs create. Onboarding a new engineer? Assign them the engineer role and they instantly get every permission that role includes. Need to audit what a user can do? Look at their roles. Want to change what editors are allowed to do across the entire app? Update the role definition once.
The anatomy of a good RBAC system
A solid RBAC implementation has three core concepts:
Roles define a persona or job function: admin, member, billing_manager, read_only. Good role design mirrors how your customers actually think about access in their organization, not how your database is structured.
Permissions define granular actions: projects:create, invoices:read, settings:write. These map to specific things your API can do, and roles are composed of sets of these permissions.
Scoping determines where a role applies. This is where most RBAC implementations diverge. In a simple system, roles are global: an admin is an admin everywhere. In a more sophisticated system, roles are scoped to an organization, workspace, or even a specific project.
Here's what a typical schema looks like:
And the authorization check at the API layer:
When RBAC works well
RBAC is the right starting point for the vast majority of B2B SaaS applications. It handles the bread-and-butter authorization needs: separating admins from regular users, restricting sensitive operations to specific roles, and giving enterprise customers the access controls they need to adopt your product.
It also maps cleanly to how identity providers (IdPs) work. When an enterprise customer connects their Okta or Azure AD instance via SCIM, they expect to be able to map their directory groups to roles in your app. RBAC makes this a natural integration.
When RBAC hits its ceiling
RBAC struggles when permissions need to vary based on context that isn't captured by a role. Here are three common scenarios where it starts to feel constraining, along with what it actually looks like in your codebase when you try to work around them.
Per-resource ownership. In RBAC, an editor can typically edit all resources of a given type. But what if editors should only be able to edit the documents they created? You can't express "editor of their own documents" with a standard role.
The workaround is always the same: you start leaking ownership checks into your business logic.
That if statement isn't authorization. It's authorization logic that escaped from your permissions model and is now hiding in your application code. Multiply it by every endpoint that needs ownership semantics and you've got a shadow permissions system that's tested by no one and understood by no one.
The alternative is creating roles like editor_project_123, which defeats the entire purpose of having roles in the first place.
Conditional access. "Managers can approve expenses under $10,000, but anything above that requires a VP." RBAC doesn't have a place to put the dollar threshold. The permission is binary: you either have expenses:approve or you don't. You end up encoding the condition in application code next to the permission check, which means your authorization rules are now split across two systems.
Cross-cutting policies. "No one can access production data outside of business hours" or "users on the free plan can't export data." These are organizational or environmental constraints that don't belong to any single role. You can technically implement them as middleware that runs before your RBAC checks, but now you have a third place where access decisions happen.
Agent and automation access. This limitation becomes especially acute when AI agents enter the picture. An agent acting on behalf of a user needs access scoped to a specific task, not to everything the user's role permits. But RBAC only knows about roles: you either give the agent the user's full permissions or you create a bespoke role for every agent-task combination. Neither option works. The first overgrants access; the second creates an explosion of roles that no one can maintain. We'll return to agents in more detail later, but it's worth flagging here because they expose the limits of flat RBAC faster than any other use case.
If you find yourself writing if statements next to your permission checks more often than not, that's the signal that your model needs to evolve.
ABAC: When rules matter more than roles
Attribute-based access control evaluates policies at request time using attributes from multiple sources: the user (department, clearance level, plan tier), the resource (sensitivity classification, owner, creation date), and the environment (time of day, IP address, device type).
Instead of a simple role lookup, an ABAC engine evaluates a policy. Here's what that looks like in practice using AWS Cedar, one of the newer policy languages designed for this purpose:
Notice how the second policy solves the conditional expense approval problem that was impossible to express in RBAC. The dollar threshold lives in the policy itself, not in a stray if statement in your application code.
When ABAC works well
ABAC shines in environments with complex, cross-cutting policies, particularly in regulated industries. Healthcare applications enforcing HIPAA rules, financial services platforms with transaction-level controls, and government systems with clearance-based access all lean on ABAC because their access rules simply can't be reduced to a static set of roles.
It's also the right tool when your policies reference attributes you don't control. If a customer's security team needs to enforce policies based on their own organizational data (department hierarchies, project assignments, risk scores), ABAC lets you express those rules without hardcoding them into roles.
The real cost of ABAC
ABAC's flexibility comes with significant operational complexity:
Policy authoring is a different skill entirely. Writing ABAC policies (in XACML, Cedar, or OPA's Rego) isn't like defining roles in a dashboard. It requires formal logic, and the failure modes are subtle. A policy that uses OR where it should use AND won't throw an error. It'll silently grant access to people who shouldn't have it. You won't find out until a penetration test or, worse, an audit. This is the kind of bug that doesn't show up in unit tests because no one thinks to test for the absence of a restriction.
Debugging is painful. When a user can't access a resource in RBAC, you check their role and the role's permissions. That's two lookups. In ABAC, you have to trace through a policy evaluation that might involve a dozen attributes, any of which could have an unexpected value. "Why was this request denied?" becomes a question that requires replaying the entire policy engine with the exact state that existed at the time of the request. Most ABAC engines provide some form of decision logging for this reason, but it's still a fundamentally harder debugging experience.
Performance requires planning. ABAC policies that reference external data sources (like an HR system or a risk scoring API) introduce latency into every authorization check. At scale, you need caching strategies, and caching authorization decisions introduces its own set of consistency challenges. If someone's clearance gets revoked, how long before the cache expires and the revocation actually takes effect?
For most SaaS applications, pure ABAC is overkill. But ABAC concepts, like enriching role-based checks with a few contextual attributes, can solve specific limitations without adopting the full complexity.
ReBAC and FGA: Permissions as a graph
Relationship-Based Access Control is the newest and, in some ways, most elegant model. Instead of asking "what role does this user have?" or "what attributes match this policy?", ReBAC asks "what is this user's relationship to this resource?"
ReBAC is the most common implementation pattern behind what the industry calls Fine-Grained Authorization (FGA). Where RBAC defines access at the role level and ABAC at the policy level, FGA defines permissions at the individual resource level. Every document, folder, project, or record can have its own precise access rules. ReBAC gives FGA its structure by modeling those permissions as a graph of relationships rather than an ever-growing list of per-resource rules.
The core idea is that permissions are derived from a graph of relationships. If you're the owner of a folder, and that folder contains a document, then you have edit access to the document. Not because someone explicitly granted it, but because the relationship chain implies it.
Google's internal authorization system, Zanzibar, popularized this model, and open-source implementations like OpenFGA and SpiceDB have made it accessible to the broader developer community. These systems are purpose-built for FGA workloads, capable of handling millions of authorization checks per second while maintaining consistency across a massive relationship graph.
Here's how you'd express a document sharing model in OpenFGA's DSL:
The key line is editor from parent. It means "anyone who is an editor of this document's parent folder is also an editor of the document." That single declaration replaces what would be hundreds of lines of manual permission propagation code.
You write relationship tuples to describe the state of the world:
And then you query the graph: "Is user:bob a viewer of document:api-spec?" The engine traverses the relationships and returns a yes or no.
When ReBAC works well
ReBAC is the natural model for applications with hierarchical or collaborative resource structures, the kind of apps where sharing and inheritance are core to the user experience.
Document and file management. Google Drive is the canonical example, but the pattern extends to any system where containers hold resources. You share a folder with a team, and everything inside it inherits that access. When someone moves a document into the folder, it automatically picks up the right permissions. Expressing "everything in this folder is accessible to this team, and everything in its subfolders too" requires one relationship tuple in ReBAC and an unbounded number of ACL entries or role assignments in other models.
Multi-tenant platforms with nested structures. If your app has organizations that contain workspaces that contain projects that contain tasks, and permissions cascade through that hierarchy, ReBAC models this naturally. An admin of the workspace is implicitly an admin of every project and task within it.
Collaborative apps with user-generated content. Any app where users create resources and share them with other users, and where the sharing model is the core product mechanic, is a strong fit for ReBAC.
Agent-mediated access. ReBAC also turns out to be the natural model for authorizing AI agents. When an agent acts on behalf of a user, the authorization question becomes: "does this agent have a relationship to this specific resource, and does the delegating user?" That's an intersection query across the relationship graph, which is exactly what ReBAC engines are designed to compute. You can scope an agent to a specific project subtree without creating bespoke roles, and the constraint propagates through the hierarchy automatically.
The tradeoffs
Operational complexity is high. A ReBAC system requires a dedicated authorization service (or a managed one) that stores and queries the relationship graph. This is a meaningful piece of infrastructure to operate, with its own availability, latency, and consistency requirements. If your authorization service goes down, every permission check in your application fails.
The mental model takes time. Developers used to RBAC's directness (check if user has permission, yes or no) need to adjust to thinking in terms of relationship tuples and graph traversal. The learning curve is real, and it affects everyone who touches authorization logic.
Not every app needs relationship traversal. If your permissions model doesn't involve hierarchies, inheritance, or sharing, ReBAC adds complexity without providing much benefit over RBAC. A flat SaaS app where admins manage billing and members use features has no graph to traverse.
Migrating between models: What the transition actually looks like
Most articles describe these models as a menu you pick from once. In reality, authorization evolves, and the migration path matters more than the initial choice.
The most common transition is RBAC → RBAC + ReBAC, and it usually goes like this:
Phase 1: you notice the workarounds accumulating. Your RBAC system works fine for org-level permissions, but you've started writing one-off ownership checks in your API handlers. Maybe you've added a created_by column to a few tables and you're checking it alongside the role check. Each new feature that involves sharing or resource-level access requires custom code instead of configuration.
Phase 2: you run RBAC and ReBAC in parallel. This is the critical step most teams get wrong. You don't rip out RBAC. You keep it for the things it handles well (org-level roles, billing access, admin vs. member) and introduce a ReBAC service for resource-level checks. Your middleware now makes two calls:
Phase 3: you backfill the relationship graph. This is the unglamorous part. Every existing resource-level permission (all those created_by checks, manual share lists, and hardcoded ownership rules) needs to be migrated into relationship tuples. For a mid-sized app, this backfill can take weeks and requires careful verification. You're essentially building a second source of truth and then proving it matches the first.
Phase 4: you delete the workarounds. Once the ReBAC service is handling resource-level checks, you can remove the scattered if statements from your application code. This is the payoff: your authorization logic is centralized again, and adding new sharing or inheritance features is configuration instead of code.
The whole process typically takes two to four months for a small team. The biggest risk isn't technical. It's organizational. You need buy-in that authorization infrastructure is worth investing in before it becomes a crisis, and that's a hard sell when the existing workarounds technically work.
Build vs. buy
One decision this guide has danced around: should you build your authorization system yourself, or adopt an existing solution?
For RBAC, building it yourself is usually fine. The schema is simple, the query patterns are well-understood, and the logic fits naturally into your existing application framework. Most teams can implement production-quality RBAC in a sprint or two.
For ABAC, the answer depends on the complexity of your policies. If you need a handful of attribute checks layered on top of RBAC, write them as middleware. There's no reason to bring in a policy engine. If you need dozens of policies that non-engineers should be able to author and audit, look at OPA (Open Policy Agent) or AWS Cedar. Both are open-source and designed to be embedded.
For ReBAC/FGA, building from scratch is almost always a mistake. The graph storage, traversal algorithms, and consistency guarantees required for a production ReBAC system are non-trivial. OpenFGA and SpiceDB are the two leading open-source options. If you want managed infrastructure, Auth0 (Okta FGA), Authzed (the company behind SpiceDB), and Permit.io are the main vendors. Evaluate them based on your latency requirements, deployment model preferences, and whether you need a hosted service or can operate it yourself.
The general rule: the more your authorization model looks like a simple lookup, the easier it is to build yourself. The more it looks like a graph or a rules engine, the more you should lean on purpose-built tooling.
Agents as principals
Every major architectural shift exposes the limits of our access control models. Multi-tenant SaaS broke filesystem permissions. Microservices broke monolithic session management. AI agents are now breaking the authorization patterns we spent the last decade standardizing.
Agents are already operating inside enterprise infrastructure. They summarize documents, triage tickets, write code, and manage cloud resources. Yet most authenticate using the same primitives as humans: an OAuth token, a session cookie, or a service account key. They inherit the user's full access or operate with broader privileges than any single person should possess.
The problem is that agents are neither users nor traditional service accounts. They are a distinct class of principal, and the distinction matters for authorization.
Agents generate their own intent. A traditional OAuth client forwards a user's request. A CI/CD runner has a fixed scope to pull code and push artifacts. But an agent reasons about a problem and decides which tools to invoke. You cannot predict at design time what an agent will need to access, which means you cannot pre-define a static role that covers exactly the right set of permissions.
The confused deputy problem. When an agent acts on behalf of a user, whose permissions apply? Consider a debugging agent with secrets:read access. A developer who lacks production access asks the agent to diff staging and production environment variables. The agent has the permission, so it retrieves the secrets and posts the diff to a shared channel. No misconfiguration occurred. The agent acted within its permissions, and the developer acted within theirs. The system failed because it never checked the intersection of their privileges.
Why static roles can't solve this. You could try to create an agent_debug_staging_only role, but agent tasks are transient and specific. In an enterprise with thousands of resources and ephemeral contexts, you'd need a unique role for every permutation of resource and permission. You move from managing a handful of roles to managing thousands, and the overhead of maintaining those static roles exceeds the value the agent provides.
This is where FGA becomes essential for agent-enabled products. Instead of granting an agent a flat role, you assign it a relationship to a specific node in the resource hierarchy. An agent scoped as viewer on project:billing can access everything inside that project subtree and nothing outside it. When the agent acts on behalf of a user, the authorization check becomes an intersection query: does the agent have access to this resource and does the delegating user? If either side fails, the request is denied.
This model also handles lifecycle cleanly. When an agent is decommissioned or its task is complete, you remove its relationship tuple from the graph. Access disappears instantly across the entire subtree. No orphaned service account keys, no stale tokens lingering for weeks.
If your product exposes any kind of agent, automation, or bot integration, treat agent authorization as a first-class design concern, not something to bolt on after launch. The access patterns agents introduce (non-deterministic scope, delegated authority, task-level granularity) are precisely the patterns that push you from RBAC toward FGA.
A practical decision framework
Ask yourself these questions about your app:
"Do permissions depend on who the user is, or on their relationship to a specific resource?" If it's the former, RBAC. If it's the latter, ReBAC (or ACLs for very simple cases).
"Do I need to enforce rules that reference contextual data like time, location, plan tier, or risk score?" If yes, you need at least some ABAC capabilities, whether standalone or layered on top of RBAC.
"Do permissions need to cascade through a hierarchy?" If sharing a parent resource should automatically grant access to its children, you want ReBAC/FGA.
"How many distinct access patterns does my app have?" If it's fewer than 10, RBAC almost certainly handles it. If it's in the dozens and they keep growing, that's a signal you need something more expressive.
"Will non-human principals like agents, bots, or automations act on behalf of users?" If yes, you need resource-level granularity (FGA/ReBAC) to scope their access to specific tasks and enforce intersection checks against the delegating user's permissions. Flat RBAC cannot model this without a combinatorial explosion of roles.
The hybrid reality
In practice, most mature applications don't use a single pure model. Here's what authorization looks like in a realistic B2B project management tool, the kind of app with organizations, workspaces, projects, and tasks.
Org-level permissions → RBAC. Who can manage billing? Invite users? Change the organization's SSO settings? These are role-based decisions. You define owner, admin, and member roles at the org level, and check them with a straightforward role lookup. This is 100% standard RBAC and there's no reason to complicate it.
Resource-level sharing → ReBAC. A project lead shares a project with a contractor. The contractor should see every task in that project but nothing else in the workspace. The project lead later creates a sub-project, and the contractor should automatically get access to that too. This is a relationship graph: contractor → viewer → project → parent → workspace. You store this in OpenFGA or SpiceDB and query it at request time. The same pattern applies to agents: if a coding agent is scoped as editor on project:api, it can modify files within that project on behalf of its delegating user, but it has zero access to project:billing next door.
Plan-tier gating → ABAC-style attribute check. Free-plan users can't export data. Enterprise users can access the audit log. These aren't role decisions (a free-plan admin and a paid-plan admin have different access) and they aren't relationship decisions. They're attribute checks: if user.org.plan_tier < "business": deny. You implement these as middleware that runs before your RBAC or ReBAC checks.
The authorization flow for any given request looks like this:
- Middleware: Check plan-tier and environmental constraints (ABAC-style). Reject early if the feature isn't available.
- Org-level: Check RBAC. Does this user's role in this org allow this category of action?
- Resource-level: Check ReBAC. Does this user have a relationship to this specific resource that grants the required permission?
All three layers are explicit, testable, and live in their own part of the stack. When a customer asks "why can't I export this project?", you can trace the denial to a specific layer: it's either the plan gate, the role, or the relationship. Compare that to a monolithic system where the answer is buried in a chain of if statements scattered across your codebase.
Start simple, evolve deliberately
The key insight across all of this is that authorization models aren't mutually exclusive. The best authorization system is the one that's just complex enough to express your product's actual access patterns, and not a bit more.
Start with the simplest model that works. Evolve when you hit real limitations, not theoretical ones. And whatever you do, keep your authorization logic decoupled from your business logic, whether that means a clean service boundary, a dedicated policy engine, or just a well-defined module in your codebase, so you can change your mind later without rewriting your app.




.webp)
_.webp)
.webp)


.webp)

.webp)
.webp)