The day RBAC stops scaling: role explosion and what comes after
Role-based access control breaks when roles outnumber users. Why per-resource permissions cause role explosion, and how to move past it without a rewrite.
Role-based access control works right up until the day it doesn't. It starts clean: a handful of roles (admin, member, viewer) mapped to a set of permissions, assigned to users. Then a customer asks for someone who can edit one project but only view the rest. You add a role. Another customer wants read access to a single report. You add another. Six months later you are staring at a permissions table where the roles outnumber the people they were supposed to describe.
That is role explosion. It catches teams by surprise because nothing broke. RBAC kept working. It just stopped scaling.
Where the roles come from
The trouble starts when permissions become tied to specific resources instead of general capabilities. RBAC assumes a role is a reusable bundle of permissions: "an editor can edit." But real products need "an editor of this project" and "a viewer of that document." The moment access has to be scoped per resource, the role stops being reusable, and the only way to express it in a pure role model is to mint a new role for each scope.
So you get editor-of-project-x, viewer-of-report-y, admin-of-workspace-z. Each one is a role in name only. It describes exactly one user's relationship to exactly one object. Multiply that across every project, document, and workspace in a growing B2B app and the count climbs past the number of users you have.

The costs are not just cognitive. Once you let each customer define their own roles, the roles table becomes one giant table linking out to permissions, and by the time you have 1,000 customers it holds a million rows and starts slowing down every authorization check in the product. Teams patch RBAC with special cases, multiply role variants, and eventually face full rewrites.
The timeline has compressed, too. Authorization models that used to evolve over a decade now change in 12 to 18 months. Workspaces, projects, nested tenants, group-based collaboration, and enterprise exceptions all arrive faster than the role table can absorb them.
What Google did instead
In 2019, Google published a paper describing Zanzibar, the authorization system behind Calendar, Cloud, Drive, Maps, Photos, and YouTube. Instead of asking "what role does this user have," Zanzibar modeled authorization as relationships between users and objects. Access became an edge in a graph: this user is an editor of this document; this group is a viewer of that folder.
A relationship check answers a different question than a role check. It asks, at request time, "can this user do this to this object," and it resolves the answer by walking the relationships that connect them. Ownership, group membership, and inheritance from a parent folder all become edges the check can traverse, rather than roles someone had to remember to create.

Zanzibar is the proof that this scales. The paper reports trillions of access control lists and millions of authorization requests per second, with 95th-percentile latency under 10 milliseconds and better than 99.999% availability across three years of production use. It also keeps decisions consistent as relationships change, so a revocation takes effect in the order events actually happened rather than eventually.
The tradeoff is architectural. Zanzibar is a centralized system that queries a database on every check, which is exactly the service most teams do not want to build and operate themselves.
The cheaper version of the same insight
The important part of Zanzibar is not its tuple storage. It is where the specificity lives. A role has to encode both what you can do and which object you can do it to, and that pairing is what makes the role count grow with your feature set times your object count. Move the object out of the role name and into the data, and editor becomes one reusable concept again.
You do not need a graph database to do that. You need resources to be first-class.
That is the shape of WorkOS Fine-Grained Authorization, which extends the existing WorkOS RBAC system rather than replacing it. It keeps the mental model of roles, permissions, and assignments, and adds hierarchical, resource-scoped access control on top. Three building blocks carry it: subjects (users, groups, devices, or agents), resources (your business entities, arranged in a hierarchy), and privileges (the roles and permissions themselves). Roles are scoped to resource types and can be assigned at any level of the hierarchy.
Permissions then flow down that hierarchy on their own. Given a structure like this:
Org: Acme (Alice: org-member)
└─ Workspace: Engineering (Alice: workspace_admin)
└─ Project: Web
└─ App: FrontendAlice's workspace_admin role on Engineering reaches every project and app underneath it without a separate assignment at each level. One assignment replaces the dozen admin-of-workspace-z variants you would otherwise mint.
The check at request time
The runtime question is the same one Zanzibar asked: can this user do this action on this resource. In practice it is one call at the boundary of the handler that does the work:
app.patch('/projects/:projectId', async (req, res) => {
const { organizationMembershipId } = req.user;
const { projectId } = req.params;
// Check if the user can edit this project
const { authorized } = await workos.authorization.check({
organizationMembershipId,
permissionSlug: 'proj:edit',
resourceExternalId: projectId,
resourceTypeSlug: 'project',
});
if (!authorized) {
return res.status(403).json({ error: 'Forbidden' });
}
// User is authorized — proceed with the update
const project = await updateProject(projectId, req.body);
return res.json(project);
});That single check() evaluates every path to access: a role assigned directly on the project, permissions inherited from the parent workspace, and organization-scoped roles. You are not walking the hierarchy yourself. When the question has a different shape, there are endpoints that match it: listEffectivePermissions for many permissions on one resource, listResourcesForMembership for one permission across many resources, and listMembershipsForResource for everyone who has a permission on a resource. That last one turns "who can touch this project" from a LIKE query against role names into an API call.
Two properties make this usable on the hot path. Org-wide roles and permissions are embedded directly in the access token, so coarse checks need no network call at all, while resource-scoped checks go to the Authorization API and evaluate against the full hierarchy. Checks are also strongly consistent with sub-50ms p95 latency, so a role change takes effect immediately instead of waiting out a cached token.
That last point is the one teams underestimate. Stuffing permissions into JWTs feels fast until the tokens get big and stale. Carta's writeup, cited in our developer's guide to RBAC, describes tokens that grew to 1MB and took a prohibitively long time to build.
How to get there without a rewrite
The instinct is to rip out RBAC and drop in an authorization engine over a weekend. Resist it. Authorization is the code path every request touches, and a big-bang cutover bets your entire access surface on a swap you cannot easily undo.
Run both models at once instead. Keep RBAC answering the checks it already answers, stand up resource-scoped checks alongside it, and migrate resource by resource, not in one cutover. This is the supported path rather than a workaround: FGA works alongside the existing RBAC product, existing roles and organization memberships keep working, and no data migration is required.

A rollout that works: keep current RBAC for organization-level access, define resource types in the dashboard to mirror your product structure, register resource instances as entities get created, introduce resource-scoped roles like workspace-admin or project-editor, add Authorization API checks where you need resource-level control, and assign resource roles to users or groups via API. Start with the corner of the product where role explosion hurts most, usually per-project or per-document sharing, and run the new check in shadow mode next to the old one before you let it decide anything.
Running side by side also gives you the audit trail a cutover skips. Every disagreement between the old and new check is a bug you find before a user does: a permission you modeled wrong, or one RBAC was silently granting that nobody meant to. When the two agree for a resource type, flip that type over and delete the roles it made obsolete.
Enterprise customers keep the part they care about. Role assignment from IdP groups still works through SSO and Directory Sync, so a customer's IT admin can map an Okta group to a role and have it land in your app, while your application code manages who has access to which specific resources.
The signal to watch
You do not need to predict the day RBAC stops scaling. It announces itself. When you catch yourself typing a resource id into a role name, the model has already told you it is out of room.
The fix is incremental. You can model one resource hierarchy, put one check() behind one endpoint, and see whether the answers hold up, without touching the roles you already ship. Sign up for WorkOS and try it free: AuthKit is free for your first million monthly active users, and staging environments are free to test in, since only production is billed.