Quick Answer

RBAC (role-based access control) attaches permissions to named roles like editor or admin, then assigns roles to users. ABAC (attribute-based access control) makes each decision by evaluating rules against attributes - who the user is, which resource they are touching, and the context such as time or ownership. RBAC is simpler to audit and show in a UI; ABAC expresses fine-grained rules like "only your own records" that RBAC cannot. Most production systems use RBAC for coarse access plus a few attribute checks.

The Core Difference

RBAC: a permission is a static fact about a role. You define once that editor grants article:update, give Asha the editor role, and the check becomes a lookup - does any of the user's roles grant this permission? The decision needs only the user.

ABAC: a permission is computed per request from attributes. Nothing stores "Asha can update article 7." Instead a rule says "a user may update an article if they own it and it is not locked," and that rule is evaluated with Asha and article 7 in hand, every time. The decision needs the user, the specific resource, the action, and often the environment.

One consequence shows up immediately. RBAC answers "what can this user do?" cheaply - list the permissions of their roles. ABAC answers "can this user do this exact thing right now?" but struggles with the general question, because the answer depends on resources that may not even exist yet.

RBAC in Code

const roleGrants = {
  viewer: ["article:read"],
  editor: ["article:read", "article:update", "article:create"],
  admin:  ["article:read", "article:update", "article:create",
           "article:delete", "user:manage"],
};

function can(user, permission) {
  return user.roles.some(role => (roleGrants[role] || []).includes(permission));
}

Verified output: an editor passes article:update (true) and fails article:delete (false); an admin passes user:manage (true). A user can hold several roles, and the check passes if any of them grants the permission, so roles add up rather than conflict.

In a real system the roleGrants table and the user-to-role assignments live in the database, and the rest is this one lookup. That simplicity is the point. It maps cleanly to a UI - a grid of checkboxes, one row per role. It is easy to audit: "who can delete articles?" is answered by "anyone with the admin role," with no code to trace. And roles line up with how organizations already describe people - support agent, billing admin, read-only auditor. Keep the permissions themselves as granular verbs like article:update, not screen names like canSeeAdminPanel; screens get redesigned, the underlying capability does not.

Where RBAC Breaks Down

The check above takes (user, permission). It never sees the article. So "editors may update their own drafts but not other people's" cannot be expressed: can(asha, "article:update") is true for every article or none. Verified - there is no argument to scope it to an owner.

Teams work around this by minting more roles: editor-marketing, then editor-marketing-us, then editor-marketing-us-contractor. Every new dimension - department, region, employment type, resource state - multiplies the role count. This is role explosion, and it ends in hundreds of near-identical roles nobody can audit, which destroys the one advantage RBAC had.

The tell that you have outgrown pure RBAC: your role names contain conditions (...-readonly, ...-own), or your permission checks start taking the resource as a second argument and branching on resource.ownerId === user.id. That branch is an attribute rule. You are already doing ABAC, just informally and without a name for it.

ABAC in Code

const policies = [
  (s, a, r)    => r.ownerId === s.id ? "PERMIT" : null,
  (s, a, r)    => a === "read" && r.department === s.department ? "PERMIT" : null,
  (s, a, r, e) => a === "update" && (e.hour < 9 || e.hour >= 18) ? "DENY" : null,
];

function can(subject, action, resource, env) {
  let decision = "DENY";                 // default deny
  for (const p of policies) {
    const result = p(subject, action, resource, env);
    if (result === "DENY") return false; // explicit deny wins
    if (result === "PERMIT") decision = "PERMIT";
  }
  return decision === "PERMIT";
}

Verified: an editor updating their own article at 11:00 is allowed; the same edit at 22:00 is denied by the time policy; reading a colleague's article in the same department is allowed; a different department is denied. Notice the request now needs the resource and the environment, not just the user - so before this runs you have to load the article and read the clock, on every check.

Two design rules carry the weight. Default deny - if no policy returns PERMIT, the answer is no, so a forgotten rule fails closed. Deny overrides - one DENY beats any number of PERMITs, so "no edits after hours" cannot be undone by "owners can edit." Get that combining logic wrong and you have a hole that passes every happy-path test. The cost of this flexibility is that "who can edit this article?" no longer has a static answer, and the policies themselves now need unit tests the way application code does.

What to Actually Build

Start with RBAC. It covers most real requirements and everyone understands it. Then layer attribute checks on top only where you actually need them:

function can(user, action, resource) {
  const roleAllows = user.roles.some(r => grants[r]?.includes("doc:" + action));
  if (!roleAllows) return false;
  if (user.tenantId !== resource.tenantId) return false;        // isolation
  if (action === "update" && !user.roles.includes("admin")
      && resource.ownerId !== user.id) return false;             // own-only
  return true;
}

Verified: a member edits their own doc but not a colleague's, everyone reads within their tenant, an admin edits any doc in their tenant but nothing in another. The role check does the coarse filtering; two small attribute checks handle the rest. This hybrid is what most SaaS ships, and it stays readable because there are only a handful of attribute rules, not hundreds.

Reach for a real policy engine - Open Policy Agent, AWS Cedar, Oso - when the rules grow numerous enough to need their own tests and review rather than if statements scattered across controllers, or when non-engineers need to read and change them. The gotcha that bites hardest: doing the role check and forgetting the ownership or tenant check, so the code passes review and every editor can edit every organization's data. Put the tenant check in shared middleware so it cannot be left out.

Frequently Asked Questions

What do RBAC and ABAC stand for? Role-based access control and attribute-based access control. RBAC grants permissions through roles; ABAC evaluates rules against attributes of the user, resource, action, and environment.
Is ABAC a replacement for RBAC? Rarely in practice. Most systems keep roles for coarse access and add attribute rules for fine-grained cases such as ownership and tenant isolation.
What is role explosion? Creating many near-duplicate roles like editor-marketing-us-readonly to encode conditions that roles cannot express. It makes the system unauditable, which was RBAC's main advantage.
Why does ABAC use default deny? So a missing or forgotten policy fails closed. If no rule explicitly permits an action, it is refused rather than allowed.
Where should the permission check run? On the server, on every request, close to the data. Client-side checks only hide UI and can be bypassed with the browser's dev tools or a direct API call.