---
title: "Supabase multi-tenant RLS: why a user in two orgs leaks data"
description: "An RLS policy that checks org membership is true in both orgs for a user who belongs to two. What my probe found, the fix, and a two-minute check."
url: "https://madsalt.tech/notes/supabase-rls-user-in-two-organizations/"
author: "Denys Zaichenko"
language: "en"
published: 2026-10-18
---

# Supabase multi-tenant RLS: why a user in two orgs leaks data

TL;DR

A policy like `auth_in_org(organization_id)` asks "is the caller a member of the org that owns this row?". For a user who belongs to org A and org B, the answer is yes in both. In my probe, RLS alone gave such a user read and write access to a document of the other org. Adding the org of the current request to the query returned 0 rows.

I found this while hardening the [construction document platform](https://madsalt.tech/work/construction-document-platform/), a multi-tenant Supabase app. Every tenant table had row level security enabled and forced, so it looked finished.

## What looked safe?

Every policy had the same shape: the row's `organization_id` must be an org the caller is a member of. Because of that, a few server actions looked a document up by id alone. The reasoning was "RLS will filter it anyway".

```ts
const { data } = await supabase
  .from('documents')
  .select('*')
  .eq('document_id', id)
  .single()
```

For a user with one membership this works, and a test with such a user passes.

## Why does RLS let the other org through?

The policy is a membership test. Postgres evaluates it for each row, as the [row security documentation](https://www.postgresql.org/docs/current/ddl-rowsecurity.html) describes, and it has no idea which organization the request is acting as, because that lives in the app: in the URL, a cookie or the org switcher. For a user who is a member of both orgs, every row of both orgs passes.

![Which org a request belongs to is not in the policy. One user, member of org A and org B, working in org A. RLS policy alone: Request in org A → Member of the row's org? → Yes, A or B → Org B row returned; RLS plus the org of this request: Request in org A → Member of the row's org? → Row in org A? → 0 rows from org B.](https://madsalt.tech/charts/en/supabase-rls-user-in-two-organizations.svg)

*My probe on a seeded user in two orgs, September 2026: RLS alone gave read and write on the org B document; adding the org of the request returned 0 rows.*

So the `organization_id` filter in the app is not a second layer on top of RLS. For this user it is the only control.

While you are in those helpers, check whether they look at `organizations.is_active`. Membership helpers often don't, and then a deactivated org keeps read and write access at the RLS layer. When the only `is_active` check lives in the app's tenant resolver, any query that skips the resolver skips deactivation too.

## What fixed it?

The org of the request became a required argument of every service function that reads or writes tenant data. A call site cannot forget it, because the function does not compile without it.

```ts
export async function getDocument(orgId: string, id: string) {
  return supabase
    .from('documents')
    .select('*')
    .eq('organization_id', orgId)
    .eq('document_id', id)
    .single()
}
```

Two more rules came out of the review:

- Never take the org from the row you are about to write. `update ... where id = $1 and organization_id = <that row's own organization_id>` is always true, so it guards nothing.
- A composite foreign key on `(id, organization_id)` makes the database refuse a link between rows of different orgs. Check every child table, it is easy to add it to three and miss the fourth.

## How do you check your own app?

Seed one user with an active membership in two orgs. Then, in the SQL editor of a local or staging copy, act as that user and ask for a row of the other org:

```sql
begin;
set local role authenticated;
set local request.jwt.claims = '{"sub": "<user id>", "role": "authenticated"}';

-- RLS alone: returns the org B document
select document_id from documents where document_id = '<org B document id>';

-- with the org of the request: 0 rows
select document_id from documents
where document_id = '<org B document id>'
  and organization_id = '<org A id>';
rollback;
```

Repeat it with an `update`, and once more after setting the second org inactive. If the first query returns a row, look at every place that queries tenant data by id alone.

## How bad is it?

Size it honestly. The attacker needs a real membership in both orgs, so this is about integrity and audit trails: someone with access to two clients edits the wrong one, and the log says it was allowed. It is not a way for a stranger to read every tenant.

The general lesson is the one from [how I test Supabase RLS](https://madsalt.tech/blog/how-to-test-supabase-rls/): test what a real user can do, with real data shapes, not only what each policy says. Three related notes: [a role taken from sign-up metadata](https://madsalt.tech/notes/supabase-raw-user-meta-data-role/), [a NULL that skipped a permission check](https://madsalt.tech/notes/plpgsql-if-not-null-fails-open/) and [which schemas the Data API serves](https://madsalt.tech/notes/supabase-exposed-schemas/). The two-company test is the first item of my [Lovable app security checklist](https://madsalt.tech/blog/lovable-app-security-checklist/). For the policies, grants and settings around it, the free [Supabase security check](https://madsalt.tech/tools/supabase-security-check/) runs 46 OWASP Top 10:2025 rules; whether a policy lets a two-org user through is a data-shape question that only the query above answers.
