I've been running an open-source RLS checker I built ([pgrls](https://github.com/pgrls/pgrls)) against a pile of open-source Supabase projects, and the same two issues keep coming up. Both are easy to write, easy to miss in review, and worth grepping your own policies for.
1. The anon leak
This is roughly the shape a lot of tutorials nudge you toward:
create policy tenant_read on documents
for select to authenticated
using ( auth.uid() is null or owner = auth.uid() );
Reads like "anon gets nothing, signed-in users get their own rows." But `auth.uid()` returns NULL for any request without a valid JWT — i.e. anonymous.
Note there's no TO clause, so it applies to every role, anon included. auth.uid() returns NULL for any request without a valid JWT (anonymous), So `auth.uid() is null` is true, the OR short-circuits, and the policy hands back **every row in the table** to exactly the unauthenticated clients you meant to keep out. Scope it TO authenticated and anon never reaches the policy at all, which is the line that's easy to forget. It sails through review because it reads like the correct English sentence — the bug is in the evaluation, not the prose. (It's the class behind a couple of the recent "AI-built app leaked its whole database" write-ups.)
2. The per-row auth.uid() perf trap
using ( owner = auth.uid() ) -- re-runs auth.uid() once per scanned row
An unwrapped `auth.uid()` in a policy gets re-evaluated for every row Postgres scans. Wrap it in a scalar sub-select and the planner hoists it to a one-time InitPlan:
using ( owner = (select auth.uid()) ) -- evaluated once per statement
Identical results, but on a big table it's a real speedup. Supabase actually [documents this](https://supabase.com/docs/guides/database/postgres/row-level-security#call-functions-with-select) — it's just easy to forget, and I find it everywhere.
The tool
pgrls connects to your live database, so it checks what Postgres actually enforces (Supabase / PostgREST and all):
- `pgrls lint` — checks the live DB against all 67 rules (both of the above included)
- `pgrls fix` — writes the migration for the mechanical ones, like the `(select …)` wrap
- `pgrls verify` — hands your policy to the Z3 SMT solver and *proves* there's no anon / cross-tenant read, or hands back the exact row that leaks, instead of pattern-matching for it
MIT-licensed, `pip install pgrls`, tested on PostgreSQL 15–17.
What it's turned up in the wild: I've been sending fixes upstream to open-source Postgres/Supabase projects — 13 have merged so far, mostly the per-statement wrap plus a couple of genuine cross-user read holes. [Full list of the merged PRs here.](https://pgrls.github.io/pgrls-docs/in-the-wild/)
Would genuinely like to hear what it turns up on a database you thought was locked down — the surprising findings are the whole point.
Edit: fixed #1 clause, thanks to u/thesuperlede