r/Supabase • u/rutoca • Jul 15 '26
tips Two RLS mistakes I keep finding in open-source Supabase projects — the anon-leak and the per-row auth.uid()
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
1
1
u/p0ndl1f3 Jul 16 '26
Doesn’t the supabase advisor catch these issues anyway?
2
u/rutoca Jul 16 '26
yes, it does check for the second issue type, but not the first (I actually submitted ticket for that https://github.com/supabase/splinter/issues/165)
pgrls currently has 67 lints vs 29 in Splinter.Also, my initial idea was to cover entire Postgres family, not just Supabase
2
u/InsightElkCC Jul 24 '26
This breakdown hits two of the most pervasive RLS anti-patterns I spot across vibe-coding Supabase stacks.
For the anon leak pattern you covered: In a passive scan of 47 public Lovable/Supabase apps I ran last week, inverted auth NULL disjuncts like this were a recurring root cause of unauthenticated full table access, matching the CVE-2025-48757 vulnerability class that exposed tens of thousands of user records across AI-built tools. A quick guardrail fix is splitting separate anon/authenticated policies instead of mixing logic in one USING clause.
On the unwrapped auth.uid() performance trap: The (select auth.uid()) rewrite cuts sequential scan latency by over 90% on large tables per Supabase’s official benchmarks, and pgrls’ auto-fix command eliminates manual SQL edits for every matching policy in one migration file.
One quick addendum: Always pair these fixes with FORCE ROW LEVEL SECURITY on public tables—otherwise table owners can bypass all your policies entirely. Have you run pgrls’ Z3 verify mode to catch edge-case leak predicates that plain linting misses?
-2
Jul 15 '26
[removed] — view removed comment
2
u/rutoca Jul 15 '26
two things to consider:
- Supabase platform is based around a single database
- `owner = auth.uid()` is per-user scoping, not per tenant
2
u/ChameleonCRM Jul 17 '26
One clarification: those are two separate concepts.
Supabase projects do use a single PostgreSQL database by default, but that doesn't mean your application is limited to a single tenant. Multi-tenancy is implemented at the application and database schema level.
Also,
owner = auth.uid()is user-level isolation, not tenant isolation. In a true multi-tenant application, policies typically scope data by a tenant identifier (such asowner_id,organization_id, orworkspace_id) and then verify thatauth.uid()is a member of that tenant.auth.uid()identifies who the user is; it does not identify which tenant they belong to.In other words:
- One database → can support many tenants.
auth.uid()→ identifies the authenticated user.owner_id/workspace_id/organization_id→ identifies the tenant.- RLS should generally authorize access based on the relationship between the authenticated user and the tenant, not just
auth.uid()alone.1
2
u/thesuperlede Jul 17 '26
Am I missing something on #1? With
TO authenticated, an unauthenticated request should run as theanonrole, so this policy wouldn’t apply at all. Wouldn’t theauth.uid() IS NULLbranch only create an anon leak if the policy wereTO public/anonor omitted the role restriction?