// Writing · Postgres

I ran Supabase’s RLS checklist against my own app. It failed four checks.

The tenant isolation was correct. Everything around it was not, and three of the four failures are the kind you cannot see by testing the app.

·8 min readSupabasePostgres RLSMulti-tenantSecurity

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.

01Performance

Not one policy said TO authenticated

02Performance

Helpers were not wrapped in (select …)

03Hardening

An UPDATE policy with USING and no WITH CHECK, on a table with a billing column

04Hardening

SECURITY DEFINER helpers sitting in the schema PostgREST exposes

01

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.

02

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.

03

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.

04

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.

05

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.

Supabase’s published figuresBeforeAfter
Index the column a policy filters on171 ms< 0.1 ms
Wrap the helper in (select …)179 ms9 ms
Same, on a heavier table178,000 ms12 ms
Restructure the join in the policy9,000 ms20 ms
Add TO authenticated (anon request)170 ms< 0.1 ms

These are Supabase’s benchmark numbers, not measurements of my app.

06

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 UPDATE policy has WITH CHECK, not only USING.
  • No column that decides entitlement is writable by the tenant, enforced by grant, not by a policy.
  • SECURITY DEFINER helpers 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.
07

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.

// Building multi-tenant?

Send me your policies and I will run this checklist on them.

If they pass, I will tell you they pass and you will have spent nothing. That is a better outcome for you than most audits.