Skip to content

← Writing

engineering

RBAC vs ABAC: Roles, Attributes, and Where Each One Breaks

· Jerwin Arnado · 8 min read ·

In the auth and permissions post I made the claim that roles are not enough. This post is the longer version of that argument. Authorization models mostly come down to two schools:

  • RBAC — Role-Based Access Control: you are what your role says. admin, editor, viewer. Permission checks ask what kind of user is this?
  • ABAC — Attribute-Based Access Control: access is a rule evaluated over attributes — of the user, the resource, the action, and the context. Checks ask can this user do this action on this specific thing, under these conditions?

Neither one is “the modern choice.” They fail in opposite directions, and knowing which failure you’re closer to is how you pick.

RBAC: the model everyone starts with

RBAC has three moving parts: users get roles, roles get permissions, code checks permissions. In Laravel the common shape is spatie/laravel-permission:

// Setup: roles own permissions, users own roles
$editor = Role::create(['name' => 'editor']);
$editor->givePermissionTo('posts.update', 'posts.publish');

$user->assignRole('editor');

// The check — note what it does NOT ask: which post?
if ($user->can('posts.update')) {
    // allowed to update posts... all of them? any of them?
}

That last comment is the whole story. RBAC is coarse by design — the check has no idea which post is on the table. And that coarseness is also its strength:

  • Auditable. “Who can publish posts?” is one query: everyone holding a role with posts.publish. Compliance teams love RBAC because the answer set is enumerable.
  • Understandable. A support engineer can look at a user’s roles and know what they can do without reading code.
  • Fast. Role membership caches trivially; checks are set lookups.

Where RBAC breaks: role explosion

The failure mode arrives with the first conditional requirement. “Editors can update posts — but only in their own department. Except regional editors, who can also read (not update) neighboring departments. And contractors lose access after their end date.”

RBAC’s only tool is more roles, so you mint them: editor-sales, editor-sales-readonly, regional-editor-sales-and-marketing, contractor-editor-sales-q3… Each new dimension (department × access level × employment type) multiplies the role count. This is role explosion, and past a point nobody can say what a role actually grants anymore — which quietly destroys the auditability that justified RBAC in the first place.

The tell in code is role checks pretending to be row checks:

// A role check impersonating a row-level rule — the RBAC breaking point
if ($user->hasRole('editor-' . $post->department->slug)) { ... }

When you’re string-building role names out of resource attributes, the attributes are the real model and the roles are ceremony. That’s the signal to move the rule where it belongs.

ABAC: rules over attributes

ABAC evaluates a policy against attributes at request time: subject (user’s department, clearance, employment status), resource (post’s department, status, owner), action (read vs update vs publish), and environment (time, IP, tenant). Laravel Policies are exactly this shape — a function of user + resource returning a decision:

class PostPolicy
{
    public function update(User $user, Post $post): bool
    {
        // subject attrs + resource attrs + context — no role math
        return $user->department_id === $post->department_id
            && $post->status !== PostStatus::Archived
            && $user->isCurrentlyEmployed();   // contractor end-date lives on the user
    }
}
$this->authorize('update', $post);   // controller, Blade, API resource — same gate

The requirements that exploded RBAC compress into three readable lines, because each condition is expressed in its native dimension instead of being flattened into role names. This is the same engine behind row-level security — row-level rules are just ABAC where the attribute is ownership.

Where ABAC breaks: nobody can answer “who has access?”

ABAC’s failure is the mirror image. Because a decision is computed, not stored, the question “who can update this post?” has no query — the honest answer is “run the policy for every user and see.” That hurts exactly where RBAC shines:

  • Audits get expensive. You can’t hand compliance a list; you hand them code and test cases.
  • Debugging is archaeology. “Why can’t Maria edit this?” means evaluating a predicate across three models, not reading a role list.
  • Policies sprawl. Without discipline, every team encodes conditions slightly differently and the “policy” is fifty ad-hoc ifs across the codebase.
  RBAC ABAC
Check asks what kind of user? can this user do this to this row, now?
Granularity coarse (class of resource) fine (specific resource + context)
“Who has access?” one query evaluate the policy per user
Scales badly when conditions multiply → role explosion rules sprawl → unauditable logic
Laravel shape roles/permissions (spatie) Policies

The hybrid every real app lands on

In practice this isn’t a choice between models — it’s a question of which layer each rule lives in. Roles for the coarse gate, attributes for the row:

// Coarse gate: RBAC answers "is this kind of user even in the room?"
Gate::before(fn (User $user) => $user->hasRole('admin') ? true : null);

class PostPolicy
{
    public function update(User $user, Post $post): bool
    {
        return $user->can('posts.update')                    // RBAC: capability class
            && $user->department_id === $post->department_id // ABAC: the row question
            && $post->status !== PostStatus::Archived;       // ABAC: resource state
    }
}

The RBAC half keeps audits answerable (“editors can update posts — here’s the list of editors”); the ABAC half keeps the rules honest (“…their own department’s, while active”). Drupal — the other half of my stack — models the same split: the roles-and- permissions grid is pure RBAC, and node access grants / query alters carry the row-level half. The pattern is older than either framework.

Caveats and best practices

  • Start RBAC, add attributes at the first row-level question. Don’t build an ABAC engine for an app with three roles and no per-row rules. Do notice the moment a role name starts encoding a resource attribute — that’s the migration signal.
  • Keep the policy in one place. ABAC sprawl is the real killer. In Laravel that place is the Policy class — if authorization conditions live in controllers, Blade, and scopes independently, they will drift. One rule, one home, enforced server-side.
  • Default deny still rules. Whichever model, an unmatched case must fail closed. A policy method that falls through to true is a breach report on a timer.
  • Make decisions loggable. For ABAC especially: when a request is denied, log which condition failed. Future-you debugging “why can’t Maria edit this” will pay it forward.
  • Test policies like the security boundary they are. Table-driven tests over the attribute combinations — department mismatch, archived post, lapsed contractor. Cheap to write, and they document the rules better than prose.
  • Know the third option exists. Relationship-based access control (ReBAC — Google’s Zanzibar, SpiceDB, OpenFGA) models access as graph relationships (“editor of this folder, which contains this doc”). When your ABAC predicates are mostly walking ownership chains, that’s the tool built for it — overkill below serious multi-tenant scale.

Conclusion

RBAC   → what kind of user: roles, enumerable, auditable — coarse
ABAC   → this user, this row, right now: policies over attributes — fine
Breaks → RBAC: role explosion; ABAC: unauditable sprawl
Hybrid → roles gate the capability, attributes gate the row

RBAC and ABAC aren’t competitors so much as layers: roles answer the auditor, attributes answer the request. Let each rule live at its natural altitude — and treat a role name with a resource attribute baked into it as the smell it is.