Every customer's data is one missing predicate away
Multi-tenant data leakage is one customer reading another's rows. Why tenant scoping fails in queries, caches, and pooled connections, and how to enforce it.
- security
- vibe coding
- engineering
Almost every app built this year is multi-tenant, whether or not the person who built it uses the word. If your product has organizations, workspaces, teams, projects, or just “accounts” that each hold their own data, you have tenants, and you have taken on the one obligation that comes with them: a row belonging to tenant A must be unreachable from a session belonging to tenant B, on every path, forever.
Multi-tenant data leakage is the failure of that obligation. It is not a bug in a library and it has no CVE of its own. It is a predicate that was supposed to be in a query and is not, and the reason it deserves its own explanation is that the missing predicate is almost never in the place you would look.
It is not the same bug as IDOR
These get conflated, and the conflation is why teams test the wrong thing.
IDOR is a per-object failure: the request names an object id that does not belong to the caller, and the handler fetches it anyway. You find it by taking user B’s invoice id and asking for it as user A.
Multi-tenant leakage is a per-boundary failure, and the caller often names
nothing at all. The request is GET /api/invoices, with no id in it. The
handler filters by user_id, correctly, and returns exactly the caller’s rows.
Then someone adds team accounts, invoices become owned by a workspace rather
than a person, the filter is rewritten to workspace_id, and one report
endpoint that nobody touched still joins through a table that carries neither
column. That endpoint now answers with rows from every workspace, and no
attacker had to guess an id to make it happen. A customer just clicked
“Export.”
That is the shape worth internalizing. IDOR is discovered by tampering. Tenant leakage is frequently discovered by a paying customer doing something completely ordinary, which is also why it tends to arrive as a support ticket rather than a report.
OWASP files the tampering half under API1:2023, Broken Object Level Authorization, and its recommended test is the right one: create accounts in two separate organizations, then use account A’s credentials to request resources belonging to organization B. Anything that comes back is an isolation failure. Hold onto that test, it is the last section of this post.
The default that leaked 38 million records
The largest documented instance of this class was not an exploit. It was a default.
In 2021 UpGuard found that Microsoft Power Apps portals exposed their list data through an OData API that was anonymously readable unless table permissions were explicitly configured. Every organization that built a portal got a working app and, unless they knew to change a setting they had no reason to know existed, a public feed of the data behind it. UpGuard notified 47 organizations. The exposure totalled 38 million records and included Social Security numbers for job applicants, COVID contact tracing data, and employee IDs, across bodies including the state of Indiana, the New York City Municipal Transportation Authority, the Maryland Department of Health, American Airlines, J.B. Hunt, and Microsoft itself. Microsoft’s fix was to change the default so that table permissions are enabled for new portals.
Nothing about that story requires a mistake by any individual developer. Forty seven competent teams built on a platform whose safe configuration was opt-in, and the platform’s own customers were the ones who leaked. That is the exact structural situation an AI builder puts you in when it generates a Supabase schema: the app works on the first request, and the boundary is a thing you have to go and add. It is the same shape whether the generator was Lovable or Bolt, because neither of them invented the default, they inherited it.
Where the scope actually goes missing
Four places, in rough order of how often we see them.
The second query. The list endpoint is scoped. The detail endpoint is scoped. The CSV export, the admin search, the webhook payload builder, and the scheduled digest job were each written later, by a different prompt, and one of them selects by a foreign key alone. There is no pattern to which one it is, which is why auditing the endpoints you remember is not a method.
The join that drops the column. posts carries tenant_id. comments
carries post_id and nothing else. A query that filters posts.tenant_id and
joins comments is fine. A query that starts from comments is not, and it reads
as perfectly reasonable code:
-- Reads fine. Returns comments from every tenant in the database.
select c.* from comments c where c.post_id = $1;
The tenant boundary was never in the comments table to begin with, so no
amount of care in that query can restore it.
The cache key. This one produces the worst version of the bug, because it
serves tenant A’s data to tenant B with tenant B’s own valid session, and it
does so intermittently. If the key is invoices:list and not
invoices:list:<tenant>, the first tenant to warm the cache decides what
everyone else sees until it expires. Every request looks authenticated and
authorized in the logs, because it was.
The pooled connection. If you scope by setting a session variable and you
run behind a pooler in transaction mode, the variable outlives your request.
PgBouncer is explicit that in transaction mode “a server connection is assigned
to a client only during a transaction,” and its compatibility table marks
SET/RESET as never supported in that mode,
with the blunt footnote that transaction pooling “breaks client expectations of
the server by design.” A plain SET app.current_tenant = ... persists on that
backend and is inherited by whichever customer’s request lands on it next.
Put the boundary under the application
Every failure above is a query that could have been written correctly and was
not. That is a bad thing to depend on, because the number of queries only goes
up and the person adding the next one is increasingly not a person. So the
boundary belongs somewhere a forgotten WHERE cannot reach it.
Here is how our own control plane does it, since it is a multi-tenant Postgres app and the schema is the argument. One function reads a transaction-local setting and returns NULL when it is unset:
CREATE FUNCTION app.current_tenant_id() RETURNS uuid
LANGUAGE sql STABLE
AS $$
SELECT NULLIF(current_setting('app.current_tenant_id', true), '')::uuid
$$;
NULL is the important part. With no context set, tenant_id = NULL is never
true, so the policy fails closed: no rows visible and no writes accepted. A
service that forgets to establish context gets an empty result, not the whole
table.
Then every tenant table carries the same two-clause policy:
ALTER TABLE projects ENABLE ROW LEVEL SECURITY;
ALTER TABLE projects FORCE ROW LEVEL SECURITY;
CREATE POLICY projects_tenant_policy
ON projects
FOR ALL
TO app_runtime
USING (tenant_id = app.current_tenant_id())
WITH CHECK (tenant_id = app.current_tenant_id());
Three details in that block do real work.
FORCE ROW LEVEL SECURITY exists because Postgres documents that “superusers
and roles with the BYPASSRLS attribute always bypass the row security system
when accessing a table. Table owners normally bypass row security as well,”
unless forced.
Migrations run as the owning role. Without FORCE, an owner-connected process
sees everything while pg_policies shows a perfectly good policy, which is the
same “the check exists and does nothing” trap that makes
RLS misconfiguration so durable. The
application connects as a separate, least-privilege app_runtime role that the
policy is granted TO, and RLS is forced so ownership is not an escape hatch.
WITH CHECK, not just USING, because USING governs which rows a statement
may read or touch and WITH CHECK governs what a row is allowed to look like
afterwards. Without it, an update can move a row across the boundary by
rewriting tenant_id, which is a write into another customer’s account and a
close cousin of mass assignment.
And the join problem from the last section is closed in the schema rather than
in the queries. projects declares UNIQUE (tenant_id, id), and every child
table’s foreign key points at that composite pair rather than at id alone. A
scan row cannot reference a project in another tenant, because the database will
not accept the pair. The comment in that migration is one line: a child row can
never reference another tenant’s project. That is a constraint doing the job an
AND tenant_id = $1 was supposed to do in every query anyone writes later.
The worker on the other side of the queue establishes the same context before it writes anything, using the transaction-local form on purpose:
func.set_config("app.current_tenant_id", ctx.tenant_id, True)
Prove it with two accounts
Reading your own code confirms what you meant. Only a request confirms what the system does, and tenant isolation is cheap to test because you do not need an exploit, just a second signup.
Create tenant A and tenant B as real customers would. Create a record in B and note its id. Then, holding only A’s session, walk your surface:
# 1. Direct fetch of B's object as A. Expect 404 or 403, never 200.
curl -s -o /dev/null -w "%{http_code}\n" \
"https://your-app.example/api/invoices/<id-owned-by-B>" \
-H "Authorization: Bearer <session-A>"
# 2. Same, but through the endpoints written last: export, search, reports.
curl -s "https://your-app.example/api/invoices/export?format=csv" \
-H "Authorization: Bearer <session-A>" | grep -c "<something-only-B-has>"
# 3. Cross the boundary by writing, not reading.
curl -s -X PATCH "https://your-app.example/api/invoices/<id-owned-by-A>" \
-H "Authorization: Bearer <session-A>" \
-H "Content-Type: application/json" \
-d '{"tenant_id":"<B-tenant-id>"}'
Test three is the one people skip, and it is the one WITH CHECK exists for. A
403 on read and a silent success on that PATCH means your boundary is one-way.
If you are on Supabase, do the unauthenticated version first, because it is
faster and it subsumes everything above: the anon key is in your bundle, so
ask the REST API for the table directly and see whether rows come back. If they
do, the tenant question is moot, since there is no tenant at all.
Which key you are holding decides how bad that is. Our
Supabase key checker decodes one in your browser
and tells you which of the two it is, and a leaked
service_role key makes every
policy in this post decorative, since that role carries BYPASSRLS by design.
If you want to know what your deployed site is actually handing out rather than
what you think it is, the
exposed API key scanner fetches the JavaScript
your page loads and reads it for credentials.
Run the two-account test again after any change that adds a role, an
organization, or a sharing feature. Those three are what turn a correct
user_id filter into an incomplete one, and the code that was right last month
does not announce that its assumption has moved.
Why this one is worth understanding rather than pattern-matching
Multi-tenant leakage is the vulnerability class least visible to the tools that
find the other classes. A secret scanner finds a string. A dependency scanner
finds a version. A SAST rule finds eval on user input. None of them can see
that a query is missing a predicate, because the query is syntactically perfect
and the predicate’s absence is only wrong given a fact about your product that
lives nowhere in the code: that workspace_id is a security boundary and
created_at is not.
That is why it belongs in the same family as
broken access control and
privilege escalation rather than in the
misconfiguration pile, and why the honest answer to “does a scanner catch this”
is: a scanner catches the enabling conditions, and a two-account test catches the
bug. Check whether RLS is on. Check whether the service role key is in the
bundle. Check whether the pooler is in transaction mode while your code calls
plain SET. Then go and try to read tenant B’s data as tenant A, because that is
the check that cannot be satisfied by a policy that exists and denies nothing.
The reason this class survives in AI-built apps specifically is not that generated code is careless. It is that generation is per-feature and isolation is per-system. Each endpoint is written correctly against the description it was given, and nothing in the loop holds the invariant that spans all of them. That invariant has to be written down once, in the schema, where the next feature inherits it without having to be told.