Engineering Atlas › System Design Fundamentals

Role-based access control (RBAC)

11 min read · last reviewed 2026-10-17

Open in the interactive app: practice, quiz, mock interview →

In one minute

How it works

Core model

Users map to roles and roles map to permissions. A check unions the permissions of the user's roles and tests membership; the resolved set changes rarely, so cache it. The model was formalised by Ferraiolo and Kuhn at NIST in 1992 and standardised as ANSI/INCITS 359-2004, revised in 2012 (NIST RBAC project). The classic reference models build up in layers: RBAC0 is users, roles, permissions and sessions; RBAC1 adds hierarchies; RBAC2 adds constraints; RBAC3 has both (Sandhu et al. 1996). The session part is often forgotten: a user activates only the roles a task needs, which is the idea behind modern just-in-time elevation of admin roles.

Hierarchy

Roles can inherit. billing-admin inherits billing-viewer, so admins get read rights without duplicating them. Keep inheritance acyclic and shallow.

Scope

In a SaaS product a user may be owner in workspace A and viewer in workspace B. Role assignments therefore carry a scope: (user, role, scope). Scopes can nest, such as organisation, project, and environment, with assignments at a parent applying to children. The check becomes: find assignments for this user whose scope is the resource's scope or an ancestor.

Constraints

Separation of duty prevents one person from holding conflicting roles, for example payment-creator and payment-approver. Static SoD blocks the assignment; dynamic SoD allows both roles but not both actions on the same object.

Enforcement architecture

A policy decision point (PDP) answers allow or deny for enforcement points in gateways and services. It can be an embedded library, a sidecar, or a central service. The UI may hide buttons, but the server must check every request. Broken access control is the number one category in the OWASP Top 10:2025, and OWASP's data found some form of it in every application tested (OWASP A01:2025).

When RBAC is not enough

"Users can edit documents they own" or "managers can approve expenses under 5,000 USD from their own reports" are per-object, attribute-based rules. Encoding them as roles leads to one role per document or per amount band. Use ABAC conditions or a relationship-based model, as in Google's Zanzibar, and keep roles for coarse job functions. Zanzibar handles trillions of ACLs and millions of checks per second with p95 latency under 10 ms (Zanzibar paper); open-source descendants such as OpenFGA and SpiceDB bring the same tuple model to ordinary teams.

Down the stack

Layer 1The contract

An RBAC decision point answers check(subject, action, resource) with allow or deny, denying by default when nothing grants access. Given the same policy snapshot it is deterministic. The important and often unstated part is freshness: after a revocation is acknowledged, how long can old grants still be honoured? Google's Zanzibar makes this explicit with consistency tokens ("zookies") so a check is evaluated at a snapshot at least as new as the content it protects, which closes the "new enemy" problem of a check seeing an old ACL with new content (Pang et al., USENIX ATC 2019).

Layer 2The mechanism

Kubernetes is a clean reference. A Role or ClusterRole holds rules made of apiGroups, resources, verbs, and optionally resourceNames. A RoleBinding attaches subjects (users, groups, service accounts) to a role within one namespace, which is the scope. The authorizer allows a request if any binding grants it; permissions are purely additive with no deny rules, so evaluation order does not matter (Kubernetes RBAC docs). The same docs warn that wildcards in resources or verbs silently grant access to resource types and verbs added later. Not every system is union-only: in AWS IAM an explicit deny in any applicable policy overrides every allow, and service control policies and permission boundaries intersect with identity policies (AWS IAM docs). Explicit deny is useful for guardrails ("nobody outside the security team may disable logging") but makes "why was this denied?" harder to answer. It also prevents privilege escalation: you cannot create a binding that grants permissions you do not hold yourself unless you have the bind or escalate verb. In application code, a fast representation numbers each permission and stores each role as a bitset, so 120 permissions fit in two 64-bit words and a check is a bitwise AND. Relationship-based systems store tuples such as doc:7#editor@user:alice and evaluate rewrite rules over them, with special indexes to flatten deeply nested groups.

Layer 3The machine

A bitset check touches one cache line and takes a few nanoseconds; a hash set lookup on permission strings costs tens of nanoseconds; a database query costs a millisecond or more. Caching effective permissions for 320,000 users at 2 KB each would be 640 MB per process, so caches are LRU-bounded per instance or kept in a shared store. Putting roles into JWTs avoids lookups but has limits: many proxies reject request headers over 8 KB (nginx returns 400 when one header field exceeds its default 8 KB buffer, per the nginx docs), so tokens carry coarse roles, not full permission lists, and a token's lifetime becomes the worst-case revocation delay. Resolving nested groups is a graph traversal whose depth must be bounded to keep latency predictable.

Why it's built this way

Organisations reason in job functions, so permissions are grouped into roles. Every request needs a decision, so effective permissions are precomputed and cached. Union-only semantics make policies composable and order independent, at the cost of no explicit deny; systems that need organisation-wide guardrails add a separate deny layer on top, as AWS does. Caching trades freshness for speed, so revocation needs invalidation or consistency tokens. Tenants must never see each other's data, so every assignment carries a scope.

Diagram

flowchart LR
  U[User alice] -->|"assigned in project-A"| R1[editor]
  U -->|"assigned in org"| R2[viewer]
  R1 -->|inherits| R2
  R1 --> P1["doc:write"]
  R2 --> P2["doc:read"]
  Q["Check: alice, doc:write, project-A/doc-7"] --> PDP[Decision point]
  PDP -->|"scope matches, editor grants"| A[Allow]

Worked example

A B2B analytics product has 8,000 customer organisations, an average of 40 users each (320,000 users), and serves 25,000 API requests per second. Every request needs an authorisation check.

The model has 6 built-in roles, enterprise custom roles, about 120 permission strings, and assignments scoped to organisation or project. About 1 million assignment rows fit easily in one indexed table.

Check path: on login the service resolves the user's effective permissions per scope and caches them in the session or a near cache, about 2 KB per user. A check is then a hash lookup, under 10 microseconds. At 25,000 RPS that is trivial CPU. Without caching, 25,000 database queries per second for authorisation alone would dominate the database.

Revocation: cached permissions outlive a removed role until refresh, so pair a 60-second TTL with invalidation events on change, and check high-risk permissions such as org:delete live.

After two years, customers have created 14,000 custom roles, many differing by one permission; usage reports let the team consolidate them.

In the wild

Trade-offs

OptionStrengthsWeaknessesChoose when
Flat RBACSimple, auditable, fast checksRole explosion for fine-grained rulesInternal tools, small permission sets
Scoped, hierarchical RBACFits multi-tenant SaaS, clear inheritanceScope resolution adds complexityMost B2B products
ABAC (attribute rules)Contextual rules on time, amount, locationHarder to audit "who can do X"Rules depend on resource or request attributes
ReBAC (relationship graph)Natural for sharing, ownership, nested groupsNeeds a graph store and consistency careDocument sharing, social or collaborative apps

Pitfalls in production

Leadership lens

Check yourself

  1. A user has doc:read but reads another tenant's document by changing an id in the URL. Which part of the check failed?

    Show answer

    The scope check. The permission was valid, but the system did not verify the resource belongs to a scope where the user holds that role.

  2. Why does "users can edit their own drafts" fit poorly in pure RBAC?

    Show answer

    It depends on a relationship between the user and a specific object. RBAC would need a role per document; an ownership attribute or relation expresses it directly.

  3. What is the difference between static and dynamic separation of duty?

    Show answer

    Static SoD forbids holding both conflicting roles at all; dynamic SoD allows both but forbids exercising both on the same transaction.

  4. Why cache resolved permissions, and what is the risk?

    Show answer

    Checks happen on every request, and caching makes them hash lookups. The risk is delayed revocation, handled with short TTLs and invalidation events.

Sources and further study

Primary

Systems docs

Books

Other curricula

Watch

Free resources

Open in the interactive app: practice, quiz, mock interview →