IDOR-safe RBAC: why checking the role is not enough
A role says what you may do, not what you may do it to. The most common data leaks in a SaaS live in the gap between those two questions.
A role says what you may do, not what you may do it to. The most common data leaks in a SaaS live in the gap between those two questions.
Almost every business application has an endpoint shaped like GET /invoices/8412 somewhere. And almost every one has, at some point, the vulnerable version of it: the one that checks whether the user has the right role, but not whether invoice 8412 is theirs.
That is IDOR — Insecure Direct Object Reference. It is not an exotic vulnerability. It is the most common real data leak in a SaaS, because it arises from something that looks already solved.
Because the role and the object answer different questions:
A system that checks only the first is perfectly coherent and completely insecure. Company A’s manager holds the manager role, so the check passes — and they receive company B’s invoice if they change the number in the URL.
The critical point: any identifier arriving from outside is untrusted input, exactly like a form field. It does not matter that your UI never displays that ID.
The simplest form is to never query by key alone, but by key plus scope:
var invoice = await db.Invoices
.Where(i => i.Id == id && i.TenantId == currentTenantId)
.FirstOrDefaultAsync();
if (invoice is null) return NotFound();
Two details that matter more than they look:
In a multi-tenant model with EF Core global query filters, the first condition comes automatically — one more reason to use them.
This is where it gets interesting. In products with an org structure — a manager sees only their reports, a director sees the whole branch — scope is not a column but a set computed from a hierarchy.
The approach that worked for me: compute the set of reachable users with a breadth-first traversal starting from the current user, then filter data by membership in that set.
What to watch out for:
Where(x => reachableIds.Contains(x.OwnerId)) translates to IN (@p0, @p1, …), and SQL Server caps a statement at 2100 parameters. A director over 3000 people does not get a slow query — they get an exception, in production, only for the largest accounts. Above a few hundred ids, join against a temporary table or a table-valued parameter instead, or push the traversal into SQL with a recursive CTE.For large hierarchies the alternative is materialising the relation (a transitive closure table). The cost moves to writes; the gain is a simple query on reads. If you go that way, the closure table must be updated in the same transaction as the hierarchy change that caused it. A closure table that lags behind by even a few seconds is not a stale cache — it is an access-control decision made on data you know to be wrong, in both directions.
The rule I rely on: if security depends on a developer remembering, it is not security.
What works:
| Mechanism | What it covers |
|---|---|
| Global query filter (tenant) | The base scope, automatically, on every query |
| Resource authorization service | “Does user X have right R over object Y?” — in one place |
Resource-based handler (AuthorizationHandler<TRequirement, TResource>) |
The same rule, reusable, invoked once the object is loaded |
| Coverage test | Asserts every identifier-taking endpoint has a check |
A word on the second and third rows, because this is where ASP.NET Core trips people up. A plain [Authorize(Policy = "...")] attribute runs in the authorization middleware — before your action executes and therefore before the resource exists. It can check claims and roles; it cannot check “does this user own this order”, because there is no order yet.
Resource-based authorization is deliberately imperative: you load the object, then ask.
var order = await _db.Orders.FindAsync(id);
if (order is null) return NotFound();
var result = await _authz.AuthorizeAsync(User, order, Operations.Update);
if (!result.Succeeded) return NotFound();
The rule lives in an AuthorizationHandler<OperationRequirement, Order> — one place, unit-testable, reused by every endpoint that touches an order. The attribute still has a job (coarse gating: is this user even allowed near the orders module), but it is not what stops IDOR.
The coverage test is what I recommend most insistently and see least often. A test that enumerates routes and flags any endpoint with an identifier parameter and no resource check catches exactly the class of bug people introduce when rushing. Note that it has to look for the AuthorizeAsync call, not just an attribute — which is precisely why the two rows above are different mechanisms.
PUT and DELETE are more dangerous than GET, and more often forgotten.The minimum test I write for each sensitive resource:
Point 3 is the one most often missing. Many teams test reads and assume writes are covered by the same checks — frequently they are not.
If you have a multi-tenant product and want a review of the authorization model before a mistake gets expensive, that is a conversation I am glad to have.
If you are building in this space, let us talk for 30 minutes.
Book a call