Skip to content
RowShield
All posts

Three bugs that look like one bug

RLS disabled, RLS enabled with no policies, and a policy that always evaluates to true are three different failures with three different fixes. Most advice treats them as one.

RowShield6 min read
rlssupabase

Search for why a Supabase table is readable and you will be told to enable row-level security. That is correct advice for exactly one of the three states a table can be in, and it is useless — occasionally harmful — for the other two.

The three are genuinely different bugs. They have different symptoms, different consequences, and only two of them share a fix.

The three states

RLS is disabled

The table has no row filtering at all. pg_tables.rowsecurity is false, and PostgREST returns every row to anyone holding a key that reaches the endpoint — which includes the anon key sitting in your client bundle.

select tablename, rowsecurity
from pg_tables
where schemaname = 'public' and rowsecurity = false;

This is the loudest of the three and the most common in generated projects, because enabling RLS is a separate statement from creating the table. From outside it looks like a 200 with a full result set.

RLS is enabled and there are no policies

Row-level security is on, and no policy grants anything. Postgres denies by default, so the correct behaviour here is to return nothing at all.

select t.tablename
from pg_tables t
left join pg_policies p
  on p.schemaname = t.schemaname and p.tablename = t.tablename
where t.schemaname = 'public' and t.rowsecurity = true and p.policyname is null;

This is not a security hole. It is a bug report waiting to happen — your application fetches an empty array and renders an empty screen, and nobody connects that to a policy because no error was raised. It matters because of what happens next: someone under deadline pressure diagnoses "RLS is breaking my app" and reaches for the fastest thing that makes rows come back, which is the third state.

A policy exists and always evaluates to true

There is a policy. It parses, it is attached to the table, it satisfies every check that asks whether a policy exists — and its condition is true for every row and every caller.

create policy "public_read"
on public.bookings for select
to anon, authenticated
using (true);

Functionally this is identical to the first state. Every row goes to anyone who asks. The difference is that it now looks protected in every summary view, which makes it the most durable of the three.

A policy that says USING (true) is not protection — it is decoration.

Telling them apart

From outside, with the anon key

The response shape separates the middle state from the other two immediately. A full result set means either RLS is off or a tautology is letting you through. An empty array with a 200 means RLS is on and nothing is granting you access — which is either correct or the second bug, depending on whether that table is supposed to be readable anonymously.

An empty array is the most misread signal in Supabase development. It does not mean "no data". It means "no rows survived policy evaluation for this caller", and those are very different statements.

From inside, in one query

Extending the join gives you all three states at once:

select
  t.tablename,
  t.rowsecurity,
  count(p.policyname) as policies,
  bool_or(p.qual = 'true') as has_tautology
from pg_tables t
left join pg_policies p
  on p.schemaname = t.schemaname and p.tablename = t.tablename
where t.schemaname = 'public'
group by t.tablename, t.rowsecurity
order by t.rowsecurity, policies;

Read the output in this order: rows with rowsecurity = false first, then rows with zero policies, then rows where has_tautology is true.

Two caveats on that last column, because a check that overstates its own accuracy is worse than no check. It catches only the literal form. A condition like owner_id is not null on a column that is declared not null, or a subquery that always returns a row, is equally tautological and will not be flagged by a string comparison. Going further than this means evaluating the expression rather than matching it.

From the dashboard, carefully

The dashboard is useful for seeing which policies exist and effectively useless for seeing what they do, because queries you run there execute as the table owner and bypass policies entirely. A table can look completely healthy in the editor and return everything to a stranger. This is the most common reason people believe a table is protected when it is not.

The three different fixes

Two of them share a fix

The first and third states resolve to the same thing: write a policy that expresses who may actually read the row, and make sure RLS is on so it is consulted.

alter table public.bookings enable row level security;

create policy "bookings_select_own"
on public.bookings for select
to authenticated
using ((select auth.uid()) = renter_id);

Two details there are not incidental. The policy is scoped to authenticated rather than including anon, so an anonymous caller is denied before the condition is considered at all. And auth.uid() is wrapped in a scalar subquery so it is evaluated once per query rather than once per row — on a large table that is the difference between a policy you keep and a policy somebody turns off for performance reasons.

If you are replacing a tautology, drop the old policy in the same migration. Leaving it in place means Postgres combines the two permissive policies with OR, and the permissive one wins every time.

The middle one has the opposite fix

RLS enabled with no policies is not fixed by adding permissions in a hurry. It is fixed by working out which access paths your application genuinely needs, and writing one policy per path, each as narrow as it can be while still letting the feature work.

The reason to separate this case out is that it is the one where the pressure runs the wrong way. The other two are silent, so nothing pushes you to fix them badly. This one breaks a screen, someone is waiting, and using (true) makes the screen work in fifteen seconds. That is the pipeline by which bug two becomes bug three, and it is worth recognising while it is happening to you.

Common questions

Does enabling RLS on every table break my application?

Very likely, and that is the reason to do it deliberately rather than under pressure. Enabling it denies everything until a policy grants access, so every broken screen is telling you about an access path you were relying on without having described it. Work through them one at a time and you end up with an explicit map of who reads what.

Is a tautological policy ever correct?

Yes, for genuinely public data — published listings, a public price list, reference tables. The problem is not the shape of the condition, it is a condition broader than intended. When you do want a truly public table, say so in the policy name, so the next person can tell the difference between a decision and an accident.

Which of the three should I fix first?

Whichever exposes data, so the first and third, ordered by how sensitive the table is. The second is a functional bug and can wait — but write down that you found it, because it is the one most likely to be resolved badly by somebody who did not know it was on a list.

Read the rule

RowShield is an independent product with no affiliation to, or endorsement from, Supabase.