Row Level Security is the part of a multi-tenant app that is either invisible or a catastrophe. It has no UI, it produces no error when it is too permissive, and the app looks identical whether it is right or wrong. So the only honest way to write about it is to audit real code, and the only code I can publish the flaws of is mine.
CrewBoard is a multi-tenant task board I built: organisations, members with roles, projects, tasks, comments, realtime. Below is what happened when I put its migration next to Supabase’s own RLS performance guidance.
Not one policy said TO authenticated
Helpers were not wrapped in (select …)
An UPDATE policy with USING and no WITH CHECK, on a table with a billing column
SECURITY DEFINER helpers sitting in the schema PostgREST exposes
First, the part that was right
I want to be precise about the headline, because “failed four checks” would be dishonest if it implied tenant data was reachable across tenants. It was not. Isolation worked.Every policy keys off membership, and no query in the app can return another organisation’s rows.
It also already avoided the trap that catches most people first. If a policy on members has to read members to decide whether you are a member, Postgres recurses and errors out. The fix is a SECURITY DEFINERfunction, which runs with the definer’s rights and therefore bypasses RLS, so the lookup does not re-trigger the policy:
This part was already correct
create function is_member(org uuid)
returns boolean
language sql security definer stable
set search_path = public as $$
select exists (
select 1 from members m
where m.org_id = org and m.user_id = auth.uid()
);
$$;So the schema passed the test everybody knows about. What it failed were the four nobody mentions.
Check 1: no policy said who it was for
A policy with no TO clause applies to public, which includes the anonymous role. Ten policies, zero TO clauses. Every unauthenticated request was therefore evaluating membership logic in order to conclude, correctly but expensively, that an anonymous user is not a member.
What I shipped
create policy org_select on organizations for select using (is_member(id));
What it should be
create policy org_select on organizations for select to authenticated using (is_member(id));
Two words. Supabase’s published test for this case reports an anonymous request going from 170 ms to under 0.1 ms, because the role check rejects the request before any policy body runs.
Check 2: the helper ran once per row
This is the one with the frightening numbers, and the reason is not obvious. Written plainly, using (is_member(org_id)) is evaluated per candidate row. Wrapped in a subquery, the planner hoists it into an initPlan and evaluates it once, then reuses the result.
What I shipped
using ( is_member(org_of_project(project_id)) )
What it should be
using ( (select is_member(org_of_project(project_id))) )
It looks like a no-op. It is not. Supabase’s own published benchmarks for this change range from 179 ms down to 9 ms on a small case to 178 seconds down to 12 ms on a heavy one.
Check 3: the one that is an actual privilege bug
This is the finding I would want a client to hold me to. My update policy on organisations read in full:
Shipped, and wrong
create policy org_update on organizations for update using (is_admin(id));
An UPDATE policy has two halves. USING decides which rows you may target. WITH CHECK decides what the row is allowed to look like afterwards. Omit the second and you have said which rows an admin may edit without ever saying what they may edit them into.
Now the specific damage. The organizations table has a plan column, and the app renders it as a Free plan or Pro planbadge. So any org admin could send a single PATCH setting their own plan to pro. Not another tenant’s row. Their own, which they legitimately administer.
And here is the part worth carrying away, because adding WITH CHECK alone does not fix it: RLS decides which rows you can touch. It has nothing to say about which columns. A billing field living on a row the tenant legitimately owns is not an RLS problem at all. The fix is a Postgres grant:
The actual fix is column-level, not a policy
revoke update on organizations from authenticated; grant update (name) on organizations to authenticated;
An admin can rename their workspace. Nothing else. If you take one thing from this article, take this one: go and look at whether any column a customer can reach decides what they are entitled to.
Check 4: the helpers were a public API
PostgREST exposes the public schema. Every one of my SECURITY DEFINER helpers lived there, which means they were not internal at all, they were callable endpoints that deliberately bypass RLS.
I am going to be honest about the severity rather than dress it up, because the useful part is the reasoning. In practice this is close to harmless here: org_of_project(uuid) only tells you which org a project belongs to if you already hold that project’s UUID, and the only way to get one is to be a member, since RLS blocks reading the table. So it is a defence-in-depth smell, not a live vulnerability.
It is still wrong, and it is wrong in a way that gets worse silently. The moment a helper takes an email, a slug, or anything else guessable, the same shape becomes an enumeration endpoint. Helpers belong in a schema PostgREST does not serve.
These are Supabase’s benchmark numbers, not measurements of my app.
The checklist, and when to ignore it
- Every policy declares
TO authenticated. - Every helper call is wrapped in
(select …), and you have checked it does not depend on the row. - Every
UPDATEpolicy hasWITH CHECK, not onlyUSING. - No column that decides entitlement is writable by the tenant, enforced by
grant, not by a policy. SECURITY DEFINERhelpers live outside the exposed schema.- Every column a policy filters on is indexed. Postgres does not index foreign keys for you.
- You tested from the client SDK. The SQL editor runs as a superuser role and bypasses RLS entirely, so it will happily tell you a broken policy works.
Where this stands
The fixes are written as a second migration in the CrewBoard repo, and they are deliberately not applied to the live project yet. CrewBoard is a demo I cite to clients and it shares a Supabase project with another one of mine, so a migration that rewrites ten policies and revokes an update grant gets scheduled and watched, not pushed because I happened to be writing about it. Saying that is more useful than pretending the tidy version was there all along.
CrewBoard itself is a demo I built and own, with Supabase auth, RLS and realtime, running on the free tier. It is not a client project.
And the honest reason this article exists: the four things above are invisible in a working app. A demo that looks right proves nothing about them, which is exactly why the interesting question to ask any developer you are hiring is not whether they used RLS. It is which of these they can tell you they checked.