Guide · October 2026

auth.uid() is null: the RLS policy that lets signed-out visitors read everything

This policy looks like it protects each user's rows. It actually hands every row to anyone who isn't signed in.

create policy "todos access" on public.todos
  for all
  using (auth.uid() is null or user_id = auth.uid());

Read it the way Postgres does. For a signed-in user, auth.uid() is their ID, so the first half is false and the second half limits them to their own rows. So far, so good.

For a signed-out request, auth.uid() is null. The first half is true, and true or anything is true, for every row. Because this is a for all policy, a signed-out visitor can also update and delete every row.

What the attack looks like

No hacking skills are needed. Your project URL and anon key are inside your app's JavaScript, and anyone can read them in the browser's developer tools. Then:

curl 'https://YOUR-PROJECT.supabase.co/rest/v1/todos?select=*' \
  -H "apikey: YOUR_ANON_KEY"

That request carries no user, so auth.uid() is null and the response is the whole table. We reproduced this on a test Postgres database set up like Supabase: the signed-out request returned another user's private todo.

How it ends up in your app

Nobody writes this on purpose to leak data. It usually appears when something didn't work:

The same hole has a few spellings. Watch for all of them:

auth.uid() is null or ...
(select auth.uid()) is null or ...      -- the "fast" style Supabase recommends
auth.role() = 'anon' or ...

Why security scans don't catch it

Supabase's Security Advisor (the database linter, which Lovable's security scan builds on) checks for patterns such as RLS being turned off, or a policy that is literally true. This policy is neither. It has a real owner check in it, so it passes.

The Supabase team knows about the gap. A proposal to detect it was opened in June 2026, with a pull request adding the lint. As of late October 2026 both are still open, so the Advisor in your dashboard does not flag it yet.

Check your project in one query

Run this in the Supabase SQL Editor. It reads only your policy definitions:

select schemaname, tablename, policyname, cmd
from pg_policies
where concat_ws(' ', qual, with_check) ~* 'auth\.uid\(\)( as uid\))? is null'
order by 1, 2;

Any row it returns is a policy that lets signed-out visitors in. To check this together with the other common mistakes (users who can make themselves admin, private storage buckets anyone can list, "any signed-in user" policies), use RLS Check. It's free and runs in your browser.

How to fix it

Remove the is null branch so only the owner gets in:

drop policy "todos access" on public.todos;

create policy "owner can manage own todos" on public.todos
  for all to authenticated
  using ((select auth.uid()) = user_id)
  with check ((select auth.uid()) = user_id);

If signed-out visitors really do need to see some rows, say so explicitly with a separate read-only policy, for example on a column like is_public:

create policy "anyone can read public todos" on public.todos
  for select to anon
  using (is_public);

Then run the curl request above again. Without a signed-in user it should return [] (or only the public rows), and the owner should still see their own data in the app.

Using Lovable, Bolt or Cursor? Paste this:

My Supabase policy "todos access" on public.todos uses "auth.uid() is null or user_id = auth.uid()", which lets signed-out visitors read, change and delete every row. Write a migration that replaces it with a policy for the authenticated role using (select auth.uid()) = user_id for both using and with check. Show me the SQL before applying it, then confirm a signed-out request returns no rows and the owner still sees theirs.

Check all your policies with RLS Check →