The Supabase RLS policy that fails open
A policy written as using (auth.uid() is null or user_id = auth.uid()) grants every row to any caller whose auth.uid() is NULL — the OR is satisfied by its first half, so the owner comparison can't save you. If the policy has no TO clause, that includes anonymous requests made with your publishable key. Delete the null-check disjunct; it is never the thing you wanted.
This is not the USING (true) policy, which at least looks wrong. This one looks defensive. It reads like "handle the case where there's no user", and it passes review because everybody's eye lands on the user_id = auth.uid() half.
create policy "read own documents"
on public.documents
for select
using (auth.uid() is null or owner_id = auth.uid());
Run that, then query the table with nothing but your anon key. You get every row, for every user, forever. No error, no warning, no log line.
Is using (auth.uid() is null or ...) safe in an RLS policy?
No. It inverts the policy it appears to be protecting.
auth.uid() reads the sub claim out of the request's JWT. When no JWT reaches Postgres it returns NULL — which is exactly the state an unauthenticated request is in. So for the caller you least want to serve, the predicate is satisfied by its first disjunct and Postgres returns the whole table.
The mental model that produces this is "NULL means we don't know who this is, so be permissive". The correct model is the opposite: NULL means nobody proved anything, so deny.
How much this costs you depends on one clause most people omit:
| Policy | Who it applies to | Anonymous caller gets |
|---|---|---|
using (auth.uid() is null or owner_id = auth.uid()) | every role, including anon | the entire table |
to authenticated using (auth.uid() is null or owner_id = auth.uid()) | authenticated only | nothing — the policy doesn't apply to anon |
A policy with no TO clause defaults to PUBLIC in Postgres, so it applies to anon as well. That's the severe case, and it's the common one, because the TO clause is optional and most snippets leave it out.
The TO authenticated version is narrower, not correct. It still fails open for any request that arrives with the authenticated role but no usable sub claim — a custom access-token hook that rewrites claims and drops sub, or a hand-minted token carrying role: authenticated and nothing else. And a policy that is only safe because of a clause somebody may delete in a later migration is a policy waiting to fail. Worth remembering too that Supabase anonymous sign-ins produce real authenticated sessions — the role name does not mean what people assume it means.
The variant family
The null-check wears several costumes. These four all fail open for an anonymous API request:
-- the canonical form
using (auth.uid() is null or user_id = auth.uid())
-- the GUC version, where `true` is the missing_ok flag
using (current_setting('request.jwt.claim.sub', true) is null
or user_id = auth.uid())
-- the same logic inverted, so it reads as a guard
using (not (auth.uid() is not null and user_id <> auth.uid()))
-- the coalesce variant: hardest to spot, because it reads as a
-- default rather than a bypass. With a NULL uid it becomes x = x.
using (coalesce(auth.uid()::text, user_id::text) = user_id::text)
Two near-relatives behave differently, and it's worth knowing why:
using (auth.jwt() is null or tenant_id = (auth.jwt() ->> 'tenant_id')::uuid)
using (auth.role() is null or user_id = auth.uid())
A PostgREST request made with the anon key still arrives with JWT claims attached — the role is anon, but the claims are there. So auth.jwt() returns a non-NULL jsonb and auth.role() returns 'anon', and neither disjunct fires. These two only fail open on a direct database connection, such as the SQL editor or psql. They're still wrong, just not in the way that leaks to the internet.
Why Supabase's database linter won't tell you
Supabase's linter (Splinter) has a rule for permissive policies — 0024_rls_policy_always_true. It doesn't catch this, for two independent reasons: it is scoped to UPDATE, DELETE and ALL commands, so SELECT policies are outside it entirely; and it matches only literal always-true forms such as true, (true), 1=1 and (1=1). A predicate that becomes true at runtime through a NULL comparison isn't literally true, so there is nothing to match.
The gap is known upstream. supabase/splinter#165, opened 3 June 2026, proposes detecting exactly this — permissive SELECT/ALL policies with a top-level OR disjunct testing nullable auth functions for NULL — and PR #169 implements it as a new lint, 0030. Both were open and unmerged at the time of writing; check their status before relying on either. Until that lands, a project can have a green advisor and a world-readable table at the same time.
To be straight with you: GuardLayer does not flag this pattern today either. Our RLS rules catch USING (true), policies that never reference the current user, tables with RLS off, and policies that trust user-editable metadata — but not the NULL-disjunct inversion.
How to find it in your own project
Two checks. First, your repository:
grep -rniE "auth\.(uid|jwt|role)\(\) is null|current_setting\([^)]*true\) is null|coalesce\(\s*auth\." \
supabase/migrations/
Then the database itself, which is what actually matters — policies created in the dashboard SQL editor never reach your repo:
select schemaname, tablename, policyname, roles, cmd, qual, with_check
from pg_policies
where schemaname = 'public'
and permissive = 'PERMISSIVE'
and (coalesce(qual, '') || ' ' || coalesce(with_check, ''))
~* '(auth\.(uid|jwt|role)\(\)|current_setting\([^)]*\))\s*is\s+null|coalesce\(\s*auth\.';
Both columns matter. INSERT policies leave qual NULL and put their predicate in with_check, so a query that only inspects qual misses every write-side fail-open policy — and with check (auth.uid() is null or user_id = auth.uid()) lets an anonymous caller insert rows attributed to anyone.
Pay particular attention to rows where roles contains {public}: that's the no-TO-clause case, and it means anon is included. And read the not (... is not null ...) inversion by hand — no reasonable regex catches it.
The fix
Delete the disjunct. That's the entire fix:
drop policy "read own documents" on public.documents;
create policy "read own documents"
on public.documents
for select
to authenticated
using (owner_id = auth.uid());
When auth.uid() is NULL, owner_id = auth.uid() evaluates to NULL, which RLS treats as "not permitted". It fails closed, which is what you wanted from the start.
If some rows genuinely are public, say so explicitly rather than encoding it as an authentication accident:
create policy "anyone can read published documents"
on public.documents
for select
to anon, authenticated
using (published = true);
Two policies, each stating one intention, both readable at a glance — and both reviewable by someone who wasn't in the room when they were written. That's the same discipline the rest of the Supabase RLS guide is built on.
FAQ
Doesn't auth.uid() is null just handle logged-out users gracefully?
It handles them by giving them everything. If you want a graceful logged-out experience, write a separate policy with an explicit predicate like published = true.
What does owner_id = auth.uid() return when auth.uid() is NULL?
NULL, not false. Postgres treats a NULL row-security check as a failure, so the row is excluded. That's the fail-closed behaviour you want.
Does adding TO authenticated make the policy safe?
It removes the anonymous path, which is the severe one. It does not make the predicate correct, and it leaves a policy whose safety depends entirely on a clause that is easy to drop in a later migration.
Will Supabase's advisor catch this eventually?
Possibly — lint 0030 is proposed in an open PR against Splinter. Check whether it has merged before relying on it; at the time of writing it had not.
My SELECT queries broke after I removed the null check. Why? Because something was reading that table without a session — usually the SQL editor, a server client that never forwarded cookies, or a build-time fetch. Fix the caller so the JWT reaches Postgres; don't reopen the policy to accommodate it.
Scan your Next.js + Supabase app — free
GuardLayer runs 34 checks on every push — exposed keys, missing RLS, over-permissive policies, unprotected routes — with the exact fix inline.
No signup, no card — your code is scanned in memory and never stored.