All posts
VibeZero Team10 min read

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.

ShareXLinkedIn