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, 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”.
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 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.
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.
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:
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: 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, a NULL that skipped a permission check and which schemas the Data API serves. The two-company test is the first item of my Lovable app security checklist. For the policies, grants and settings around it, the free Supabase security check runs 45 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.