How five RBAC failure modes bypass object-level authorisation
Five RBAC failure modes show how route checks, UI gating, tenant scope gaps, workflow state and service identity bypass object-level authorisation across APIs.
RBAC fails in real applications because authorisation is often bolted on after the product has already decided what its objects, workflows and APIs look like. The role matrix arrives late, usually as a spreadsheet, then developers are asked to map it onto routes that were never designed around policy decisions. The result looks orderly in an admin screen and behaves badly under adversarial use.
The failure is rarely that a table named roles is missing. Most teams have one. The failure is that the table is asked to carry decisions it cannot express: ownership, tenant boundaries, delegated authority, workflow state, break-glass access, partial revocation and service identity. Those decisions end up scattered through controllers, middleware, React components, background jobs and database queries. Once that happens, RBAC has stopped being an access-control model and become a naming convention.
This is why many broken access control findings look dull in isolation. A user changes an ID and reads another account. A support role exports data outside its tenant. A disabled user still has an active session. An API endpoint accepts a request that the UI would never have displayed. None of these require cryptography to fail. They require the application to confuse authentication, navigation and authorisation.
RBAC is not a permission spreadsheet
Role-based access control is useful because it groups permissions around organisational responsibilities. Finance can approve invoices. Support can read tickets. Administrators can manage accounts. That sounds simple because the examples are simple. Real systems are not.
The original RBAC model separates users, roles, permissions and sessions. A user is assigned to roles. Roles contain permissions. A session activates a subset of a user's roles. Constraints can limit combinations, such as preventing the same person from both creating and approving a payment. This structure matters because authorisation is about relationships between actors, actions and resources.
Many implementations keep only the vocabulary. They create users, roles and permissions, then flatten everything into checks such as if user.role == "admin". That works until the first customer asks for a regional administrator, a billing-only manager, a read-only auditor, a contractor with an expiry date or a support engineer who can impersonate a user only during an open case. The model needs constraints. The implementation often has only strings.
The spreadsheet approach makes the problem worse. Product teams list screens down one axis and roles across the other. A tick means the role can see the screen. This is a navigation model, not an authorisation model. It says nothing about which records can be touched, whether a workflow transition is valid, whether the action is being performed on behalf of someone else or whether the same permission should apply through an API, a background task and a bulk import.
Once the spreadsheet becomes the source of truth, engineering tends to encode it at the easiest visible boundary: the route or the menu item. That is where the architectural rot starts.
Route-level checks are too coarse
Route-level RBAC is attractive because it is easy to reason about. Put middleware in front of /admin/users, require the admin role and move on. For coarse administrative surfaces, that may be sufficient. For most business operations, it is not.
A route only tells the application which handler is being invoked. It does not tell the application whether the caller may act on the specific object being requested. A user with access to GET /projects/:id may be allowed to read one project and forbidden from reading another. A manager may approve expenses for direct reports but not for another department. A customer administrator may create users inside their own tenant but not in a sibling tenant owned by the same reseller.
These are object-level decisions. They require resource context. If the enforcement point only sees the route and the user's global role, it cannot make the correct decision. Developers then patch the gap with local checks inside handlers: compare tenant_id, verify ownership, inspect workflow state, special-case superusers and remember to do the same thing in every other path that touches the object.
Every other path includes APIs, batch jobs, webhooks, exports, imports, search indexes, GraphQL resolvers, mobile endpoints and internal service calls. If one path checks only the route and another checks the object, the weaker path becomes the policy.
A correct design pushes the decision closer to the domain action. approve_invoice should be protected as an action on an invoice, not as a POST request on a URL. The policy needs the subject, action, resource and relevant context. Without those four pieces, the system is guessing.
UI gating is presentation logic
A hidden button is not a security boundary. This is repeated so often that it should be unnecessary, yet real applications keep treating front-end state as if it were enforcement.
The pattern usually starts innocently. The front end receives a list of permissions and hides actions the user cannot perform. This is good usability. It prevents confusion and reduces accidental errors. Then someone assumes the API must be receiving only valid requests because the UI would not show invalid actions. At that point, usability has been promoted into security without the step of enforcing anything.
Attackers do not use the UI the way product teams do. They call APIs directly. They replay requests. They change identifiers. They inspect mobile traffic. They use old clients. If the server trusts the front end to decide what is allowed, the attacker has been handed the policy engine.
Single-page applications make this failure more common because permissions become visible data. A client-side route guard checks whether canManageUsers is true. A component hides the delete button. A disabled form field prevents editing. None of those controls survive a direct HTTP request. The only useful question is what the server does when the request arrives.
There is still value in client-side permission checks, but only as presentation logic. The server should be able to return a denial for every forbidden action even if the client is malicious, outdated or entirely absent. Anything less is decoration.
Permission sprawl is a design smell
A growing permission list often looks like maturity. It is usually the opposite. When a system has hundreds of permissions with names tied to screens, buttons and implementation details, the authorisation model has started mirroring the product's accident history.
CAN_VIEW_REPORTS, CAN_EXPORT_REPORTS, CAN_EXPORT_REPORTS_V2, CAN_VIEW_NEW_DASHBOARD, CAN_MANAGE_ADVANCED_SETTINGS and CAN_MANAGE_ADVANCED_SETTINGS_EXCEPT_BILLING tell a story. The story is not that the system has precise access control. The story is that every new feature added another local exception.
Sprawl creates three security problems. Nobody knows what a role actually means. Permissions become inconsistent across surfaces. Revocation becomes unreliable because cached entitlements, active sessions, API tokens, delegated grants and downstream services may outlive the role assignment.
This is how least privilege dies without anyone explicitly rejecting it. Administrators copy an existing role, add the one missing permission and accidentally inherit excessive access. Developers reuse a read permission for export because the names look close enough.
The cure is not simply fewer permissions. The cure is to model permissions around domain actions and resource types, then treat roles as bundles that can be reasoned about. If a role cannot be described without reading a long diff of feature flags, it is not a role. It is a landfill.
Multi-tenancy breaks naive RBAC
The most common fatal assumption in RBAC implementations is that a role is globally meaningful. In a single-tenant internal application, admin may be tolerable. In a multi-tenant system, admin is incomplete. The missing phrase is "of what".
A tenant administrator is powerful inside one boundary and ordinary outside it. A reseller may manage customer accounts but not the provider's internal configuration. A user may belong to multiple organisations with different roles in each. A consultant may have temporary access to a project in one workspace and no access to the rest of the tenant. A platform support engineer may need limited access across tenants under audited conditions.
If the role assignment is stored only as user_id, role_id, the design has already lost tenant context. Developers then recover context in application code by adding checks wherever they remember. Some endpoints filter by tenant. Some trust a tenant ID from the request. Some infer tenant from the user's default organisation. Some forget entirely.
Tenant isolation needs to be part of the authorisation primitive, not an afterthought added to queries. The policy decision should know which tenant, account, workspace or project scopes the role applies to. The data layer should make cross-tenant access hard by default, preferably through scoped queries or row-level protections where appropriate. Logs should record the effective tenant context for sensitive actions.
Multi-tenancy also collides with administration. Operators need support access, security teams need investigation access and billing systems need cross-tenant aggregation. These requirements are real, but they are also where broad bypasses tend to appear. "Internal admin" becomes a magic role that ignores every tenant boundary because modelling constrained support access was considered too slow.
Workflows need state-aware authorisation
RBAC is often implemented as if permissions are static. Business processes are not. Whether an action is allowed may depend on the state of the object, who performed previous steps, time limits, risk scores or separation-of-duty rules.
Consider invoice approval. A finance manager may have permission to approve invoices, but not invoices they created. They may approve only below a threshold. They may need a second approver above that threshold. Approval may be forbidden after payment has been issued. A global APPROVE_INVOICE permission cannot encode those rules on its own.
The same pattern appears in account recovery, code deployment, medical records, procurement, customer support and content moderation. The role opens the door to a category of actions. The workflow decides whether this specific action is valid now.
When state is ignored, applications permit impossible transitions. A closed ticket is edited. A cancelled order is refunded twice. A user reinstates themselves after suspension through an old endpoint. A draft document is published by someone who only had review access before the state changed. These are not always labelled RBAC bugs, but they are authorisation failures.
State-aware authorisation requires policy checks to sit beside domain invariants. The code that changes an object from pending to approved should enforce both the business rule and the access rule. Splitting them between a route guard and a service method invites drift.
Service-to-service calls inherit the same mistakes
Modern applications rarely perform an action in one process. A user request may pass through an API gateway, application service, job queue, worker, data service and notification system. Somewhere along that path, the original authorisation context often evaporates.
One failure mode is over-trusted internal networks. A backend service accepts requests from another service and assumes they are authorised because they are internal. If the upstream service has a missing check, the downstream service becomes an amplifier. If an attacker can reach an internal endpoint through SSRF, misrouting or compromised credentials, the service has no independent policy boundary.
Another failure mode is confused identity. The worker runs as a powerful service account and performs actions originally requested by a low-privilege user. Logs show the service account. The data layer sees the service account. The policy engine, if one exists, may see the service account. The human actor disappears, along with the reason the action was allowed.
The answer is to preserve authorisation context deliberately. Services need to know whether they are acting as themselves, on behalf of a user or as part of a system process with its own narrowly defined authority. Background jobs that perform privileged actions need explicit policy, not inherited omnipotence.
Internal does not mean authorised. It only means the request came from somewhere embarrassing to have trusted blindly.
Testing should prove denial
Access-control tests often confirm that permitted users can perform actions. That is necessary and insufficient. The more important tests prove that forbidden users cannot.
A useful RBAC test suite has a denial matrix. For each sensitive action, it covers users with no role, the wrong role, the right role in the wrong tenant, the right role on the wrong object, expired delegation, disabled accounts, stale sessions and workflow states where the action should be blocked. These tests are not glamorous. They are the difference between a policy and a hope.
Reviews need the same discipline. A pull request that adds a new endpoint should answer four questions before merge:
- What subject is performing the action?
- What resource is being acted on?
- What context affects the decision?
- Where is the denial tested?
If the answer is "the route is admin-only", the review is not finished.
What better architecture looks like
There is no single correct authorisation architecture, but resilient systems share a few properties.
The first is central policy expression. This does not require a fashionable policy language or a separate service, although those can be appropriate. It requires one place where the rules are expressed consistently enough that engineers do not invent local variants.
The second is domain-level enforcement. Policies should protect actions the business understands: approve invoice, rotate key, export records, invite user, close case. HTTP routes, buttons and message handlers are transport details.
The third is scoped roles. Role assignments should include the boundary in which they apply: tenant, organisation, project, environment or account. Global roles should be rare, heavily audited and named honestly.
The fourth is explicit context. Policies need facts: object owner, tenant, state, sensitivity, delegation, time, assurance level and actor type. Passing those facts into the decision makes the policy reviewable.
The fifth is operational lifecycle. Authorisation is not done when the feature ships. Roles change. Staff leave. Emergency access is granted and forgotten. Tokens outlive users. A real design includes review, expiry, logging, alerting and break-glass procedures.
None of this makes RBAC simple. It makes the complexity visible. That is the point.
The architectural antipattern
Authorisation-as-an-afterthought is an architectural antipattern because it treats access control as a layer that can be painted over a finished application. It cannot. Authorisation is part of the domain model. It shapes identifiers, queries, workflows, service boundaries, audit logs and user administration. If those structures are built first and policy is added later, the policy will conform to the existing architecture rather than constrain it.
This is why RBAC failures persist despite decades of guidance. The industry has learned the nouns but not the placement. Users, roles and permissions are easy to draw. Enforcement points, resource context and denial tests are harder to retrofit. So teams ship the easy nouns, then discover that every real decision lives somewhere else.
RBAC is not broken. The afterthought version is. A role model that cannot answer "of what", "on which object", "in which state" and "through which path" is not an access-control system. It is a directory of intentions.
The uncomfortable lesson is that authorisation has to be designed before it is needed most. By the time a product is large enough to demand precise access control, the shortcuts are already load-bearing.
Newsletter
One email a week. Security research, engineering deep-dives and AI security insights - written for practitioners. No noise.