Skip to content
RowShield
All posts

Four policies that say nothing

A policy whose condition is always true satisfies every tool that checks whether a policy exists. Four ways to write one, only the first of which is obvious.

RowShield5 min read
rlspolicies

A tautological policy is one whose condition evaluates to true for every row and every caller. It is the most durable of the row-level security failures, because it satisfies every check that asks whether a policy exists — the dashboard shows a policy, the advisor is quiet, a reviewer sees a create policy statement in the migration — while permitting exactly as much as having no policy at all.

Only the first of the four forms below is recognisable at a glance. The other three are why "does this table have a policy" is the wrong question.

The four forms

1. The literal condition

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

Nothing to interpret. Every row, every caller. This is the one that string-matching finds, and it is common in generated projects for an understandable reason: it is the fastest thing that makes a broken screen work when row-level security has been enabled and no policy grants anything yet.

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

2. A condition on a column the client controls

create policy "own_bookings"
on public.bookings for select
to anon, authenticated
using (renter_id = (current_setting('request.jwt.claims', true)::json ->> 'sub')::uuid
       or is_public = true);

This one reads like a real policy, and half of it is. The problem is is_public, a column the client can set on insert. Any caller who can create a row with is_public = true has granted themselves read access to it, and depending on your write policies, possibly to rows they do not own.

The general form: any predicate whose truth is determined by data the caller supplies is not an authorisation check. It is a filter that the caller controls.

3. A subquery that always returns a row

create policy "member_access"
on public.bookings for select
to authenticated
using (
  exists (select 1 from public.organisations)
);

The intent was clearly membership. What was written asks whether the organisations table has any rows in it at all, which it does, for everyone, forever. The policy is a tautology dressed as a join.

This form is the hardest to spot in review because it is long. It looks like work was done. The correct version has to correlate the subquery with the row being tested and with the caller:

using (
  exists (
    select 1
    from public.organisation_members m
    where m.organisation_id = bookings.organisation_id
      and m.user_id = (select auth.uid())
  )
);

Two correlations, both required. Drop either one and you are back to a condition that is true whenever the table is non-empty.

4. A role list that includes everyone

create policy "authenticated_only"
on public.bookings for select
to public
using ((select auth.uid()) is not null);

Two problems compounding. The policy is granted to public, which in Postgres means every role including anon — not "the general public" in the loose sense, but literally every role. And the condition tests only that a caller is signed in, not that the row is theirs.

The result is that any authenticated user reads every row on the table. In a multi-tenant product this is the most damaging of the four, and it is the one most likely to survive review, because "must be logged in" genuinely sounds like a security condition.

Writing the scoped version

Scope to the owner

The general shape for a user-owned table. Note the two details that are not cosmetic: the policy is granted to authenticated rather than public, so an anonymous caller is denied before the condition is evaluated at all, and auth.uid() is wrapped in a scalar subquery so it is evaluated once per query rather than once per row.

drop policy "public_read" on public.bookings;

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

The drop is not optional. Postgres combines permissive policies for the same command with or, so leaving the tautology in place while adding a correct policy alongside it changes nothing — the permissive one still matches every row.

Scope through membership

When ownership is indirect, correlate on both sides as in form 3 above. If the join gets expensive, that is a real problem and the answer is an index on the columns the policy touches, not a looser policy. A policy that makes a table too slow to query is a policy somebody eventually disables.

When the table really is public

Sometimes using (true) is correct — published listings, a price table, reference data. In that case say so, in the policy name, so the next person can distinguish a decision from an accident.

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

That name costs nothing and it is the difference between a finding and a documented choice.

Common questions

Can a scanner detect all four of these?

The first reliably, by matching the condition. The fourth reliably, by reading the role list. The second and third require evaluating the predicate rather than reading it, which is why the honest answer to "are my policies correct" always ends with making a request and seeing what comes back. Any tool claiming to catch every tautology by inspecting policy text is overstating what inspection can do.

Why does a generated fix drop and recreate rather than alter?

Because alter policy cannot change everything that needs changing — the role list in particular — and because a drop-and-recreate is visible in a migration diff in a way an in-place alteration is not. If you are applying a generated fix, read it first: it should name your table, your ownership column and your existing policy name, and if it contains a placeholder you are expected to fill in, that placeholder is the most important line in the file.

What if I do not know which column holds ownership?

Then that is the first thing to establish, and it is usually a user_id, owner_id or renter_id referencing auth.users. If a table genuinely has no ownership column and no path to one through a join, it either belongs to everybody or to nobody, and both answers are worth deciding deliberately rather than inheriting from a policy someone wrote in a hurry.

Read the rule

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

RowShield checks what a deployed Supabase app exposes: a free anonymous, read-only probe, and scheduled policy-metadata and drift checks for connected projects. Run a free audit.

RowShield is a Veristria product. More about RowShield.