All articles
Multi-tenant SaaS · 6 min read ·

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.

securityRBACIDORmulti-tenant.NET

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.

Why is the role check not enough?

Because the role and the object answer different questions:

  • Role: “does this user have the right to view invoices?” — a question about capability.
  • Ownership: “do they have the right to view this invoice?” — a question about scope.

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.

What does an ownership check look like?

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:

  • The filter is in the query, not after it. If you load first and check afterwards, you already hold the object in memory — and it is easy to miss a path where the check is absent.
  • You return 404, not 403. A 403 confirms the resource exists. For resources outside the caller’s scope, “does not exist” is the correct answer from an information-leak standpoint.

In a multi-tenant model with EF Core global query filters, the first condition comes automatically — one more reason to use them.

What if scope is more than the tenant?

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:

  • cycle protection — a badly administered hierarchy can contain loops; keep a visited set;
  • a depth limit, so a data error does not turn into an expensive loop;
  • compute once per request and keep the result in request scope, not on every check;
  • invalidate when the hierarchy changes, if you cache across requests;
  • do not pass the set back as query parameters. This is the failure that surprises people: 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.

How do you avoid repeating the check in every controller?

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.

What is not a solution?

  • Hard-to-guess IDs. A GUID is useful defence in depth, but it is not access control. Identifiers end up in logs, in shared URLs, in reports. And “hard to guess” assumes more than most teams check: EF Core generates sequential GUIDs by default for SQL Server, because random ones fragment the clustered index. Sequential means partially predictable — see one, infer its neighbours. If unpredictability is part of your argument, generate the value from a cryptographic RNG deliberately, and accept the index cost.
  • Hiding it in the UI. A missing button does not protect an endpoint.
  • Checking reads only. PUT and DELETE are more dangerous than GET, and more often forgotten.
  • Trusting the referer or call ordering. The client can call anything, in any order.

How do you test it?

The minimum test I write for each sensitive resource:

  1. two tenants, each with a resource;
  2. authenticated as tenant A, request B’s resource → expect 404;
  3. authenticated as tenant A, modify B’s resource → expect 404 or 403, never success;
  4. a user lacking the required role, on their own resource → expect 403.

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.

The project behind this article eMAG Marketplace Automation A SaaS hub for eMAG sellers — orders, AWB, automated pricing and fraud detection.

Working on something similar?

If you are building in this space, let us talk for 30 minutes.

Book a call
All articles