[Deep dive]

How IDOR happens in multi-tenant code

The object-level bug is one fact wide: the caller names the record and the query trusts it. One screen of code, why the tests pass anyway, and the shape of the fix.

Aevral,

Multi-tenancy turns IDOR from an occasional bug into a structural one. Every row carries a tenant id, every session carries a tenant id, and the distance between those two facts is where the object-level failure lives. OWASP ranks it first in the API Security Top 10 for a reason: API1:2023, Broken Object Level Authorization, is the class where attackers manipulate the ID of an object sent within the request, and the server relies on that client-supplied parameter to decide which objects to access. This page reads one instance of it slowly, in one screen of code.

The bug is one fact wide

The object-level failure needs exactly one fact to be wrong: a lookup keyed by a value the caller controls, with the owner of the record asserted somewhere else, or not at all. CWE-639, Authorization Bypass Through User-Controlled Key, names it: the system's authorization logic does not prevent one user from gaining access to another user's data by altering the key value that identifies the data. The key is an id in a path segment, a query string, a header, or a payload field, and the data type is irrelevant.

In a multi-tenant app the shape has three parts. The session carries the tenant binding, an organization id resolved by the auth layer. The row carries the same binding, a column the schema already has. The query carries neither, because it was written against the record, not against the rule. The rule, that a member of org A may read an invoice of org A, is enforced by a relation that never appears on one line. That is why the flaw survives a per-line read: every line involved is individually correct.

The shape, in one screen of code

A route in an Express service backed by an ORM, reduced to its access surface. Three facts meet here: requireSession resolves the caller and the session carries the tenant binding, the row carries the same binding as a column, and the query uses neither. It is a legal, idiomatic query, and that is the point.

invoices route, before and after the scope clause
  // requireSession resolves the caller; the session carries orgId.
  app.get("/invoices/:id", requireSession, async (req, res) => {
-    const invoice = await db.invoice.findUnique({
-      where: { id: req.params.id },
-    });
+    const invoice = await db.invoice.findFirst({
+      where: { id: req.params.id, orgId: req.session.orgId },
+    });
     if (!invoice) return res.status(404).json({ error: "not_found" });
     res.json(invoice);
  });

What the read answers, and why the fix is a scope clause

The before-version is not broken code in any per-line sense: the record exists, so the read succeeds and the response is a true statement about existence. The missing fact is scope. Nothing ties the record to the caller's tenant, so a member of org A who names an id belonging to org B receives org B's invoice. The OWASP entry describes exactly this: by design, the user has access to the endpoint, and the violation happens at the object level, by manipulating the ID (API1:2023, Broken Object Level Authorization; the related weaknesses include CWE-639, Authorization Bypass Through User-Controlled Key).

The after-version answers a different question: not whether the record exists, but whether it exists in the caller's tenant. The 404 becomes the denial, and deny-by-default falls out of the scoping: a record outside the tenant is indistinguishable from a record that is not there. One line separates the two versions, and the rule that line rewires is the one worth stopping a review for.

Why the tests pass anyway

Functional suites authenticate and act on their own objects: create a tenant, create a record in it, assert the happy path. The cross-tenant probe, a member of org B requesting org A's invoice and expecting the denial, exists only if someone wrote it. The OWASP prevention list for API1:2023 names both halves of the fix: use the authorization mechanism to check whether the logged-in user has access to perform the requested action on the record in every function that uses an input from the client to access a record in the database, and write tests to evaluate the vulnerability of the authorization mechanism.

Two near-fixes deserve a line each. Comparing the session user id with the id parameter is, per the same OWASP entry, not a sufficient solution: the violation happens at the object level, and a user-id compare addresses a small subset of cases. Random and unpredictable GUIDs are the other published mitigation, and they help as a narrowing layer: enumerating sequential ids becomes guessing a namespace. They do not replace the authorization check on the record; a UUID that reaches a log line, a share link, or a support ticket is a caller-named key again.

A cross-file reading, closed by a human

The query, the session, and the gate live in different files, and the rule lives in none of them. That is what makes this class a reading job: the unit of meaning is the relation between the caller-named key, the session tenant, and the query scope, and relations move in refactors, when a scope clause is dropped, lifted into a helper, or left off the new caller. It is the cross-file reading the whole-repo scan encodes over the default-branch snapshot, and the reading Aevral's opt-in PR security review applies to pull requests, as findings that are leads: the file, the lines, the reason a human should look, and a fix prompt for the coding agent.

A finding stays a lead. A human reads the evidence and decides whether the rule really moved; the agent fixes the gate and a human reviews before merge. Nothing in the loop claims a catch rate or patches on its own. The durable verification is the cross-tenant probe written into the test suite, because the probe is what keeps the rule from moving quietly the next time.

Sources

OWASP API1:2023 Broken Object Level Authorization; CWE-639: Authorization Bypass Through User-Controlled Key; OWASP Authorization Cheat Sheet; OWASP Authorization Testing Automation Cheat Sheet.

Read next

What a whole-repo authorization scan reads; Reviewing a pull request for access control; Working a scan report of access-control leads; The scan product.

More guides


A security agent for your code.

Install the GitHub App, claim your organization. Press Scan for the default branch. New organizations start with PR review on; existing opt-outs stay off.