Supabase · Security ·
Supabase RLS Policies That Pass Review but Still Leak Data
Row Level Security is switched on. Every table has a policy. The policies mention auth.uid(). A reviewer skims the migration, sees all three, and approves it. And one signed-in user can still read every other user's row.
This guide pairs with Ship-Ready AI Rules, and if you want the next step, read Supabase Service Role Key Exposed? How to Check, Rotate and Stop It Happening Again.
This post covers two patterns like that. Both came from readers of our earlier Dev.to article, 7 security bugs AI agents write in Next.js + Supabase + Stripe apps, and both are easy to miss because they look right:
- A policy that checks the user is signed in, but not that the row is theirs.
- A free-credit grant that treats a new account as a new person, which Gmail dot and plus aliases get around.
All the SQL below was run on a local PostgreSQL 17 database with a stand-in auth.uid() function. Supabase behaviour is quoted from the Supabase RLS docs, checked on 15 October 2026. This site isn't affiliated with Supabase.
The policy that checks you're logged in, not that the row is yours
cekuu35 pointed this out in the comments. Our article suggested sweeping for qual = 'true', and they noted that's only the first pass. Once an agent is told not to write using (true), it tends to write this instead:
create policy "Users can view profiles"
on public.profiles for select
to authenticated
using (auth.uid() is not null);
It reads well. RLS is on, there's a policy, and the policy mentions auth. A qual = 'true' check won't flag it, because the expression isn't literally true.
But look at what it actually tests. auth.uid() is not null is true for every signed-in user, on every row. Nothing ties the row to the person asking. So any logged-in user can query the table with the anon/publishable key and get everyone's email, stripe_customer_id and whatever else is in the table. One line in the browser console is enough:
await supabase.from('profiles').select('*')
In our local test, a signed-in user with this policy saw 2 of 2 rows, including the other user's.
The policy is only correct when the table really is meant to be readable by every logged-in user, like a public directory. For anything per-user, it's a leak.
Why reviews and linters miss it
The usual checks all pass:
- RLS enabled? Yes.
- A policy exists for each operation? Yes.
- No
using (true)? Correct, there isn't one. - Mentions
auth.uid()? It does.
None of those ask the question that matters: does the expression compare the row's owner column to the caller? The Supabase docs show the owner-scoped shape for every operation. For select it's:
using ( (select auth.uid()) = user_id )
Insert uses the same comparison in with check. The docs also point out that auth.uid() returns null for unauthenticated requests. That's why a not-null test feels like a security check. It does stop signed-out users, but it stops nobody else.
Find these policies with pg_policies
Postgres exposes every policy in the pg_policies view. qual holds the USING expression and with_check holds the WITH CHECK expression, both as text. This query lists policies in public where either expression doesn't compare auth.uid() to something with =. That catches bare true, auth.uid() is not null, and anything else that never ties the row to the caller:
select schemaname, tablename, policyname, cmd, roles, qual, with_check
from pg_policies
where schemaname = 'public'
and (
(qual is not null
and qual !~* 'auth\.uid\(\)[^=]*=|=[^=]*auth\.uid\(\)')
or (with_check is not null
and with_check !~* 'auth\.uid\(\)[^=]*=|=[^=]*auth\.uid\(\)')
);
Run it in the Supabase SQL editor. On our test database, it returned the auth.uid() is not null select policy and a with check (true) insert policy, and 0 rows after the fix below. Postgres stores (select auth.uid()) as ( SELECT auth.uid() AS uid); the pattern allows for that.
Treat the results as a review list, not a verdict. It's a text match, so:
- Expected hits: genuinely public tables, and team tables scoped through an
exists (...)membership check. Check each one and make sure it's intentional. - What it can't see: an
=that comparesauth.uid()to the wrong column will pass. So will a policy that joins through an unfiltered table. The regex also passes any expression that contains a comparison somewhere, even when something else makes it broad. Both of these return 0 rows but scope nothing:
using ( auth.uid() is not null or <anything> )
using ( auth.uid() = auth.uid() )
cekuu35's comment includes their own version of the regex and a runnable red/green demo. The extra step we'd add is a two-user test: sign in as user A and try to read and update user B's row. That catches the shapes a pattern match won't.
The fix: scope USING and WITH CHECK to the owner
Replace the not-null test with a comparison against the owner column, one policy per operation the app needs:
drop policy "Users can view profiles" on public.profiles;
create policy "Owners read own profile"
on public.profiles for select
to authenticated
using ( (select auth.uid()) = user_id );
create policy "Owners insert own profile"
on public.profiles for insert
to authenticated
with check ( (select auth.uid()) = user_id );
create policy "Owners update own profile"
on public.profiles for update
to authenticated
using ( (select auth.uid()) = user_id )
with check ( (select auth.uid()) = user_id );
create policy "Owners delete own profile"
on public.profiles for delete
to authenticated
using ( (select auth.uid()) = user_id );
Leave out the delete policy if users shouldn't delete their own rows; with RLS on, no policy means no deletes.
Some notes from the Supabase docs:
usingvswith check:usingdecides which existing rows a user can see or target.with checkdecides what a new or updated row is allowed to look like. On updates, you need both: withoutwith check, a user could update their row and setuser_idto someone else's. In our test, that reassignment failed with this error, which is what you want:
new row violates row-level security policy for table "profiles"
- The
(select ...)wrapper: wrappingauth.uid()in(select ...)lets Postgres evaluate it once per statement instead of once per row. It's a performance detail, not a security one, but it costs nothing. to authenticated: this keeps the anon role out entirely. Signed-out requests then can't match the policy at all.
If you hit that same error on inserts after tightening policies, a common cause is .insert().select() (or an upsert): returning the new row also needs a matching select policy, so keep the select policy alongside the insert one.
Free credits and Gmail aliases: a new account isn't a new person
elijahbrown raised the second pattern in the same thread. Treating "account just created" as proof someone should get free credits doesn't hold up. Anyone can sign up again with jane.doe+2@gmail.com or j.a.n.e.doe@gmail.com and look brand new. Gmail ignores dots in the local part and everything after a +, so all of those reach the same inbox.
This isn't an RLS bug, strictly speaking. It's the same mistake, though: trusting something that looks like identity but isn't. Their suggested fix is to key the credit grant on a normalised email in a separate claims table with a unique index, and only grant the credits when that insert succeeds.
Here's a normalisation function. It lowercases every address. For gmail.com and googlemail.com it also strips dots and any +suffix from the local part, and maps both domains to gmail.com:
create or replace function public.normalize_email(addr text)
returns text
language sql
immutable
strict
as $$
select case
when split_part(lower(trim(addr)), '@', 2) in ('gmail.com', 'googlemail.com')
then replace(split_part(split_part(lower(trim(addr)), '@', 1), '+', 1), '.', '')
|| '@gmail.com'
else lower(trim(addr))
end
$$;
It's marked immutable so it can feed a stored generated column; Postgres only allows immutable functions in generated columns. Then the claims table:
create table public.trial_claims (
id bigint generated always as identity primary key,
user_id uuid unique references auth.users (id) on delete set null,
email text not null,
email_normalized text generated always as (public.normalize_email(email)) stored,
claimed_at timestamptz not null default now()
);
create unique index trial_claims_email_normalized_key
on public.trial_claims (email_normalized);
alter table public.trial_claims enable row level security;
-- No policies for anon/authenticated: only your server code writes here.
There's deliberately no on delete cascade: if deleting an account also deleted its claim, the same inbox could delete, sign up again and claim a second time. With on delete set null, the claim row (and its normalised email) stays after the account is gone. The unique on user_id also stops one account claiming twice.
Grant credits only when the claim row is actually inserted:
insert into public.trial_claims (user_id, email)
values ($1, $2)
on conflict do nothing
returning user_id;
If the returning comes back empty, someone already claimed with that inbox, or this account already claimed. (on conflict do nothing with no target covers both unique checks.) In our test, Jane.Doe+x@GoogleMail.com and j.a.n.e.doe@gmail.com both normalised to janedoe@gmail.com, and the second claim inserted nothing. Because the unique index does the check, two signups racing each other can't both win.
Run the insert from server code, such as a Route Handler, Server Action or webhook, using the email from the verified session, not one sent by the browser.
Limits worth being honest about:
- Gmail is easy; other domains aren't. Some other providers support plus addressing too, and you could extend the function, but rules vary by provider. Lowercasing everything else is the safe default.
- Disposable email domains get past this entirely. Keep the free credit small, or put larger grants behind a verified payment method.
- Store the original email too. Use the normalised one only for the uniqueness check, never as the address you send to.
A quick checklist
- Run the
pg_policiesquery above and review every row it returns. - Every per-user table: an owner comparison in
usingon select, update and delete, and inwith checkon insert and update. to authenticatedon policies that should never apply to signed-out users.- A two-user test in CI: user A can't read or update user B's row.
- Free credits keyed on a normalised email with a unique index, granted only when the insert succeeds.
Related
- If you see
permission denied for tableinstead, that's a missing grant, not RLS. See the Supabase Data API grants change. - Our free checker is MIT-licensed and runs locally. It flags tables without RLS,
using (true)/with check (true)write policies, anon write policies and missing grants in your migration files. It's heuristic and won't catch every policy shape above, so it's a first pass, not proof.
Thanks to cekuu35 and elijahbrown for raising these in the comment thread.
Disclosure: this is our own paid product. If you want your coding agent steered towards this from the start, AI Rules ($19; UK buyers pay $22.80 incl. VAT) includes a Supabase RLS migration skill. It tells the agent to write owner-scoped policies using (select auth.uid()) for each operation. There's also an RLS audit skill that treats using (true) / with check (true) as critical and requires ownership to come from auth.uid(). It doesn't replace code review or a two-user test.
Sources (checked 15 October 2026):
- Supabase: Row Level Security
- PostgreSQL: pg_policies
- PostgreSQL: Generated columns
- Dev.to comments by cekuu35 and elijahbrown