RBAC vs ABAC vs ReBAC: choosing app authorization that survives growth
For application developers deciding how to model authorization beyond simple roles. This guide compares RBAC, ABAC, and relationship-based access control in the request path, walks one realistic example end to end, and shows concrete policy/config patterns you can adapt.
TL;DR — Most teams should start with RBAC for coarse app/admin permissions, add ABAC for context like region, plan, or resource state, and move to relationship-based access control when permissions depend on graph-like sharing rules such as "viewer via team membership" or "editor because Alice shared folder X with Bob". The common failure mode is trying to stretch RBAC to model per-resource sharing; if you are adding roles like
project_123_editor, stop and switch to relationships. Reading time: ~7 min
What it is and where it sits
For application developers, this is not an identity problem first; it is a request-decision problem. A user or service principal arrives with authenticated identity claims, your app loads resource context, then an authorization layer answers one question: "may subject S perform action A on resource R under context C?"
RBAC, ABAC, and relationship-based access control differ mostly in what data they evaluate:
- RBAC evaluates assigned roles, usually attached to users or groups.
- ABAC evaluates attributes on the subject, resource, action, and environment.
- ReBAC evaluates graph relationships between subjects and resources.
In a typical web/API stack, authorization sits after authentication and before the business operation or database write. It may live:
- inside the app as library calls like
authorize(user, action, resource) - in middleware/API gateway for coarse endpoint checks
- in a sidecar or separate policy service for centralized decisions
- partly in the database via row-level security, with app-supplied session context
What it replaces depends on maturity:
- hard-coded
if user.is_adminchecks scattered through handlers - endpoint-only checks that ignore resource ownership/sharing
- ad hoc SQL filters duplicated across services
A realistic flow looks like this:
View as text
[Client] ---- 1. GET /docs/42 ----> [API]
|
| 2. authn: verify JWT/session
v
[Identity Claims]
^
|
| 3. load user/team attrs,
| resource attrs or relations
v
[Policy Decision]
|
| 4. allow/deny + reason
v
[API] ---- 5. query DB / return 403 ----> [Postgres]Architecture-wise:
- RBAC often lives comfortably in app code and JWT/group claims.
- ABAC usually needs fresh data lookups because attributes change more often than tokens.
- ReBAC usually needs a relationship store or graph-like tuples table and a traversal/check engine.
The IAM angle: authentication tells you who; authorization models tell you why this request is allowed right now.
How it actually works
Use one example: a B2B docs app with organizations, teams, folders, and documents.
Requirement:
- Org admins can do anything in their org.
- Team members can view docs in folders shared with their team.
- A document can be directly shared with a user as editor.
- Suspended users can read nothing.
- Draft documents are editable only by owners and direct editors, not just viewers via team share.
This is where pure RBAC starts to bend. Walk the request:
PATCH /documents/42 by user u_17.
Step 1: authenticate and extract stable claims
The API verifies the session/JWT and gets claims like:
{
"sub": "u_17",
"org_id": "org_9",
"roles": ["member"],
"teams": ["team_design"],
"user_status": "active"
}
These claims are useful, but not enough. They do not tell you whether document 42 is shared with team_design, whether u_17 is a direct editor, or whether the doc is in draft state.
Step 2: load resource context
Your handler or policy service fetches authorization data. In SQL terms, that usually means one query for the resource and one for relevant relationships, or one denormalized read model.
For document 42, suppose you load:
{
"document": {
"id": 42,
"org_id": "org_9",
"owner_id": "u_3",
"state": "draft",
"folder_id": 7
},
"relations": [
{"resource": "document:42", "relation": "editor", "subject": "user:u_17"},
{"resource": "folder:7", "relation": "viewer", "subject": "team:team_design"}
]
}
Step 3: evaluate policy
Now compare how each model would answer can u_17 edit document 42?
- RBAC only:
memberis too coarse. You would need roles likedoc_editor,folder_editor, maybe evendocument_42_editor. That is role explosion. - ABAC: you can say active users in the same org may edit when
resource.owner_id == user.idorresource.shared_editors contains user.idandresource.state != archived. But team-based inherited sharing gets awkward unless you model set membership and inheritance in attributes. - ReBAC: you define relations like
document#editor@user:u_17andfolder#viewer@team:team_design, plus rules likedocument viewer if parent folder vieweranddocument editor if direct editor or owner. Draft edit policy can still use attributes such asdocument.state == draft.
In practice, the best answer here is often hybrid:
- RBAC for org-level admin/operator permissions
- ReBAC for sharing and inherited access
- ABAC for state/context constraints like
user_status=active,org_id match,document.state=draft
Step 4: return a decision with a reason
A useful decision payload is not just true/false. Return enough metadata for logs and debugging.
{
"allow": true,
"policy": "document.edit",
"reason": "direct_editor",
"subject": "user:u_17",
"resource": "document:42"
}
Or on deny:
{
"allow": false,
"policy": "document.edit",
"reason": "draft_requires_owner_or_direct_editor",
"missing": ["owner", "editor"]
}
That reason string matters when you are diagnosing support tickets and writing audit logs.
Step 5: enforce consistently
The app must use the same decision for:
- endpoint access
- UI affordances like hiding the Edit button
- background jobs acting on behalf of users
- database reads/writes if you implement row-level security
If your UI checks one rule and your API checks another, users will hit the classic "button visible, save returns 403" bug.
When to use it (and when not to)
| Scenario | Recommendation |
|---|---|
Internal admin app with a few fixed roles (admin, support, billing) | RBAC. Keep it simple and store role assignments in your app DB or IdP groups. |
| SaaS app where access depends on department, region, plan, data classification, time, or resource state | ABAC, usually with some RBAC. Good when decisions depend on many changing attributes. |
| Product with sharing, nested resources, teams, delegated access, "can view because shared via folder/project/org" | ReBAC, usually with RBAC + ABAC around it. |
| You are creating roles per customer, project, document, or row | Do not keep stretching RBAC. That is the signal to move to ReBAC or DB-level policies. |
| Single-tenant app with one admin and a couple of staff permissions | You probably don't need ABAC or ReBAC. Use RBAC and explicit ownership checks in code. |
| Highly regulated data access where policy must reference labels/classification and environmental context | ABAC or hybrid. ReBAC alone will not express enough context. |
| Latency-sensitive hot path with millions of checks/sec and simple rules | RBAC or precomputed ABAC. ReBAC graph traversals can be expensive without caching/materialization. |
You probably do not need ReBAC if all permissions can be explained as a small fixed matrix of role × action. You probably do not need ABAC if your "attributes" are really just hidden roles with extra steps.
Trade-offs
Every benefit costs something operationally.
-
RBAC benefit: easy to explain, easy to cache in tokens, fast checks. Cost: role explosion when permissions become resource-specific; painful exceptions like "everyone in team A except contractors".
-
ABAC benefit: expressive policies over user/resource/environment state; good fit for compliance and data classification. Cost: attribute freshness becomes a real problem. If
user_status=suspendedchanges, a long-lived JWT may still say active. You either shorten token TTLs, introspect tokens, or fetch fresh attributes on each request. -
ReBAC benefit: natural model for sharing, delegation, nested resources, and inherited access. Cost: relationship graph storage, traversal complexity, consistency bugs, and harder mental models for developers. Debugging "why can Bob read this doc?" requires explanation tooling, not just inspecting a role column.
-
Central policy service benefit: consistent decisions across services. Cost: network latency and another dependency in the request path. If the policy service is down, decide now whether your app fails closed with
403/503or fails open for some internal paths. Most security-sensitive apps should fail closed. -
Database enforcement benefit: fewer app bypasses, especially for direct SQL/reporting paths. Cost: harder local development, session-context plumbing, and more complex query plans. Postgres RLS can surprise you when a query suddenly returns zero rows instead of throwing an obvious auth error.
Lock-in risk is mostly about policy language and relationship engine semantics. If you choose a specialized external engine, keep your domain model explicit in your app: subjects, resources, actions, attributes, and relationships. That makes migration possible.
In practice
Example 1: simple RBAC middleware in Express
function requireRole(...allowed) {
return (req, res, next) => {
const roles = req.auth?.roles || [];
const ok = allowed.some(r => roles.includes(r));
if (!ok) {
return res.status(403).json({
error: "forbidden",
required_any_role: allowed,
actual_roles: roles
});
}
next();
};
}
app.delete("/admin/users/:id", requireRole("org_admin", "support_admin"), async (req, res) => {
res.status(204).end();
});
This does coarse endpoint authorization and returns a debuggable 403 body. Gotcha: if you put roles only in JWTs with a 1-hour TTL, revoking a role may take up to an hour unless you rotate tokens or check a revocation source.
Example 2: ABAC/ReBAC-friendly policy input and decision in Open Policy Agent style
{
"input": {
"subject": {
"id": "user:u_17",
"org_id": "org_9",
"status": "active",
"roles": ["member"],
"teams": ["team_design"]
},
"action": "document.edit",
"resource": {
"id": "document:42",
"org_id": "org_9",
"owner_id": "user:u_3",
"state": "draft",
"folder_id": "folder:7"
},
"relations": {
"document_editors": ["user:u_17"],
"folder_viewer_teams": ["team:team_design"]
}
}
}
package authz
default allow := false
reason := "no_matching_rule"
allow if {
input.subject.status == "active"
input.subject.org_id == input.resource.org_id
input.subject.roles[_] == "org_admin"
reason := "org_admin"
}
allow if {
input.subject.status == "active"
input.subject.org_id == input.resource.org_id
input.resource.state == "draft"
input.relations.document_editors[_] == input.subject.id
reason := "direct_editor"
}
allow if {
input.subject.status == "active"
input.subject.org_id == input.resource.org_id
input.resource.state != "draft"
some t
input.subject.teams[_] == t
input.relations.folder_viewer_teams[_] == sprintf("team:%s", [t])
input.action == "document.view"
reason := "team_folder_viewer"
}
This shows the hybrid pattern: role-based admin override, attribute checks for org/state/status, and relationship-derived access for sharing. Gotcha: if relation data is incomplete or stale, policy will deny correctly but unexpectedly; log the input version or relationship snapshot timestamp.
Example 3: Postgres row-level security for tenant isolation plus app-set context
⚠️ Enabling RLS on a table can instantly hide rows from your application if you have not set the session variables your policy expects. Roll this out in staging first and test with the exact application role.
ALTER TABLE documents ENABLE ROW LEVEL SECURITY;
CREATE POLICY documents_tenant_read ON documents
FOR SELECT
USING (org_id = current_setting('app.org_id', true));
CREATE POLICY documents_owner_write ON documents
FOR UPDATE
USING (
org_id = current_setting('app.org_id', true)
AND owner_id = current_setting('app.user_id', true)
);
BEGIN;
SET LOCAL app.org_id = 'org_9';
SET LOCAL app.user_id = 'user:u_17';
SELECT id, title FROM documents WHERE id = 42;
COMMIT;
This enforces tenant and owner checks in the database. Gotcha: current_setting(..., true) returns NULL if unset, so comparisons become NULL and deny access by returning no rows; your app may see an empty result, not an explicit permission error.
Further reading
- NIST Role-Based Access Control model
- NIST SP 800-162 Guide to Attribute Based Access Control
- Google Zanzibar: Google’s Consistent, Global Authorization System
- PostgreSQL documentation: Row Security Policies
- Open Policy Agent documentation: Policy Language and REST API
This article was written by an AI system and published pending human review. Verify anything you intend to act on.
Have a project in mind?
Get an instant AI price estimate for it, or talk directly to our team.
One email a month on what we learn building with AI