Multi-tenant IDOR: why the tests never catch it
One manager could read another venue's unpublished drafts by changing a digit in the URL. The flaw was not in the authorisation code: it was in its absence on a single route, and no test could have caught it.
On a multi-venue management application, each manager administers their own venue and nothing else. The rule was obvious, it was written down, and it was enforced — almost everywhere.
One route did not enforce it. A manager who changed an identifier in the URL of an edit page could see another venue’s drafts. Not their payment details, not their accounts: their unpublished listings. Enough to know a direct competitor’s menu and prices before they went live.
No alert fired. No test failed. That silence is what this article is about.
Why no test could have caught it
The tests existed — and they passed. They passed because they tested the wrong thing.
An application test is naturally written from the point of view of a single account: I sign in, I create a record, I edit it, I check it is correct. Every step of that journey is legitimate. It never crosses a boundary, so it cannot detect that a boundary is missing.
The second blind spot is about who writes the test. Whoever has just written the route tests it with the mental model they held while writing it. If that model contained “of course the caller will only ask for their own records”, the test contains it too.
A test only reveals a tenancy flaw if a second account exists. This is the only shape that works:
1. Account A creates a resource, note its identifier
2. Account B signs in normally
3. Account B requests A's identifier
4. The test passes if, and only if, the response is 404
Note the 404, not the 403. A 403 confirms the resource exists — that is already an information leak. An account with no right to see a resource must not be able to infer that it exists.
The root cause
The authorisation existed. It was enforced in the presentation layer: the template only offered a manager the listings of their own venue, and the interface never built a link to anything else.
That protects the interface, not the data. It holds as long as the caller uses the interface. It does not hold for a second once someone types the URL by hand — which is exactly what people do when they go looking.
The data layer, meanwhile, received an identifier and served it:
-- What the route did: it trusts the caller
SELECT * FROM listing WHERE id = :id;
Nothing in that query says who the listing belongs to. Security rested entirely on nobody calling the route with an identifier that was not theirs.
The fix, and why it was not enough
The immediate correction is one line:
-- The account's scope is part of the question being asked
SELECT * FROM listing
WHERE id = :id
AND venue_id = :account_venue;
An identifier belonging to another venue now returns nothing. The 404 falls out naturally, with no extra code, because the resource does not exist within the scope of whoever is asking.
But fixing that route does not fix the problem. One uncomfortable certainty remained: if one route had been missed, others could have been, and nothing stopped the next route written from being missed too. A code review catches that kind of omission about half the time, and only if the reviewer happens to think of it that day.
What actually solved it was making the omission impossible rather than detectable: a single data-access function per table, taking the account context as its first argument, which cannot be called without it.
// Impossible to call without a scope: the parameter is mandatory
// and it comes from the session context, not from the HTTP request.
listing.byId(context, id)
The difference is structural. In the first version, writing a safe route required remembering a rule. In the second, writing an unsafe route requires deliberate effort. That is the only kind of rule that survives six months of development and a handover to someone else.
The point that matters: the context comes from the session, never from the request. A route
that accepts venue_id as a parameter from the client has fixed nothing — it has just moved
the flaw from one field to another.
The two other flaws found in the same place
Looking for the first one surfaced two more, and they come from the same habit of thought.
A parameter exposed inactive data to unauthenticated requests. A filter meant for administration — also show what is hidden from the public — was accepted on a public route. The original reasoning: “this parameter is only used by the back office”. The same reasoning as the first flaw, on a different field.
Session sharing between tabs caused an identity mismatch. Two accounts open in two tabs of the same browser ended up acting for one another, depending on request ordering. Rare in real use, but perfectly reproducible — and on a self-service kiosk, not rare at all.
All three share one root: an assumption about the caller’s behaviour, taken for granted instead of verified. That is the very thing a zero-trust architecture sets out to eliminate, long before networks or encryption enter the conversation.
What we take from it
Three rules, applied on every project since.
Scope belongs in the query, not the view. An access check that lives in the template
protects the interface, not the data. If the filter is not in the WHERE clause, it does not
exist.
Context comes from the session, never from the client. Everything arriving in an HTTP request is a proposal from the caller, not a fact.
A project with no flaws found is not a safe project — it is a project nobody looked at. These three were sought, found, fixed and documented before going live. That is the only moment at which they cost nothing.
We would rather publish this than let anyone believe software gets built without mistakes. What separates a serious project from the rest is not the absence of flaws: it is what is put in place so that you find them, and not somebody else.
A question on this, or a comparable problem on your side?Write to us.