3 Supabase RLS Leaks I Found in Production Apps This Week (and the 30-Second Check for Each)
I audit Supabase apps for a living. This week I read the public source of three real, live products — a university dorm-management system, a freelancing marketplace, and an embedded product configurator — and every single one had a Row Level Security hole that its developer did not know was there.
None of these are exotic. Each one comes from a decision that looks reasonable for about five seconds. I notified all three owners (I only ever read public source, never their databases), and two of the patterns are so common that you should assume you have one of them until you check.
Here are all three, with the 30-second check for each, so you can verify your own app right now.
Pattern 1: The "email exists" helper that opens the whole table
The dorm system's login page needed to check whether an email was registered before sending the user down the right flow. The migration that added this contained, roughly:
-- Login sahifasidagi anon email tekshiruvi uchun
CREATE POLICY "Anon email existence check"
ON public.staff FOR SELECT
TO anon
USING (true);
The comment says "for the login page's anonymous email check." The policy does that. It also does something else: USING (true) on FOR SELECT TO anon means anyone holding the anon key can read every column of every row of the staff table — names, emails, phone numbers, role, which floor of the dorm they're assigned to.
The intent was "can this one email be checked?" The implementation was "can this whole table be downloaded?"
30-second check (Supabase SQL editor):
select policyname, roles, cmd, qual
from pg_policies
where roles::text = '{anon}';
Any row here with qual = true on a table that holds user data is a finding. You are looking for policies whose blast radius is the entire table when the intent was a single lookup.
The fix: never open the table for a lookup — wrap the lookup in a function that only returns what the login page needs:
create or replace function public.email_exists(tbl text, email text)
returns boolean
language sql security definer
set search_path = public
as $$
-- SECURITY DEFINER: runs with table-owner rights, no RLS policy needed
select exists (
select 1 from public.staff s
where s.email = email_exists.email
);
$$;
-- now close the table
revoke all on public.staff from anon;
drop policy "Anon email existence check" on public.staff;
SECURITY DEFINER + an EXISTS wrapper gives the login page a yes/no answer while the table itself stays sealed. This exact pattern — a helper policy that quietly widens to USING (true) — is the most common critical I find, and it's the one AI codegen tools produce when you ask them to "let the login page check if the email exists."
Pattern 2: The policy named "Users can read" that actually means "anyone can read"
The marketplace had this, and honestly it's the sneakiest one because the policy name reassures you:
CREATE POLICY "Users can read all profiles"
ON profiles FOR SELECT
USING (true);
Read it again: there is no TO clause. In Postgres, a policy with no TO applies to public — which includes anon. The name says "Users." The behavior is "any person or script with your anon key," and the profiles table included email addresses for every freelancer and client on the platform.
If you learned RLS by reading policy names, you'd ship this. The names lie; only pg_policies tells the truth.
30-second check:
select policyname, roles, qual
from pg_policies
where roles::text = '{public}';
{public} means "everyone including anon." If the table holds anything you wouldn't print on your marketing page — emails, private flags, internal IDs — that's a finding.
The fix: say who you mean.
drop policy "Users can read all profiles" on profiles;
create policy "Users can read all profiles" on profiles
for select
to authenticated -- <- the line that was missing
using (true);
If public browsing is genuinely intended (public marketplace profiles, for example), expose a view that projects only the public columns instead of the base table. The base table with emails stays behind authenticated — or behind nothing at all.
Pattern 3: The service-role key with a VITE_ prefix
The configurator had a committed .env.vite.local with three real credentials, and one line made it worse than the other two:
SUPABASE_KEY=sb_secret_... # real secret key
SUPABASE_SERVICE_ROLE_KEY=eyJ... # real service_role JWT
VITE_SUPABASE_SERVICE_ROLE_KEY=eyJ... # the same JWT, client-named
Vite inlines every VITE_-prefixed variable into your shipped JavaScript. The VITE_ prefix on a service-role key is a deployment decision that says "put the key that bypasses all RLS into a bundle every visitor can download." Even if the variable is never referenced in code, the commit itself puts the key in public git history forever.
30-second check: paste your committed service-role JWT into jwt.io. If the payload says "role": "service_role" and it still authenticates against your project, you're on the clock — not embarrassed, just on the clock. This is a rotate-first situation:
1. Supabase dashboard → Settings → API → rotate the service_role key + secret key.
(Do this FIRST: rotation kills the leaked value instantly. Deleting the file does nothing.)
2. Remove the env file from the repo, add it to .gitignore.
3. grep the codebase for the VITE_ variable and delete every use.
Client code should only ever hold the anon key. Server-only secrets
belong in server-side env or an edge function.
The full 30-second audit
If you do nothing else, run this in your Supabase SQL editor today:
-- 1. Policies open to anon with USING(true)
select schemaname, tablename, policyname, cmd
from pg_policies
where roles::text = '{anon}' and qual = 'true';
-- 2. Policies open to everyone (no TO clause) with USING(true)
select schemaname, tablename, policyname, cmd
from pg_policies
where roles::text = '{public}' and qual = 'true';
-- 3. Tables with RLS disabled entirely
select c.relname
from pg_class c
join pg_namespace n on n.oid = c.relnamespace
where n.nspname = 'public'
and c.relrowsecurity = false
and c.relkind = 'r';
Empty results on all three is the baseline you want. Any hit deserves the question that matters: what was this policy meant to do, and what does it actually allow? Almost every leak I find is a policy whose intent and blast radius never matched.
The method I use — plus a bigger catalog of these patterns and the fix SQL for each — is open here: github.com/cekuu35/supabase-rls-leak-demo. Steal it, run it against your own migrations, and fix what it finds before someone less polite reads your source.
If you'd rather have someone run all of this against your app and hand you a severity-ranked report — that's literally what I do, say hi. No other pitch here.
Stay safe out there — and read your policies like an attacker, not like a config file.
All three apps were found through their public source only. Owners were notified the same day; no databases were accessed.
Top comments (1)
The
email_existsfunction seals the table, but the login page calls it with the anon key, so anyone can still ask it whether a given address belongs to a staff member, one guess at a time. If the login flow can respond the same way whether or not the email is registered, sending the link or code either way, the helper isn't needed at all. If it has to stay, comparing lower(trim()) on both sides is worth adding, since the exact match returns false for Jane@ when the row was saved as jane@.