I've been auditing Supabase apps for a while now. Last week I ran my RLS security scanner across every public Supabase-related repository I could find on GitHub. The results confirm what I suspected: most apps have at least one security issue that the developer doesn't know about.
The methodology
The scanner reads public source only — migration files and .env files fetched via raw.githubusercontent.com. No databases accessed, no credentials used, nothing stored. It checks for three things:
-
Committed secrets —
service_roleJWTs andsb_secret_keys -
Permissive RLS policies —
USING(true)open toanonorpublic -
Write-all policies —
USING(true) WITH CHECK(true)for authenticated
What I found
~15% had committed secrets
Roughly 15 out of 100 had a real service_role JWT or sb_secret_ key committed to the public repo. A few had the key under a VITE_ or NEXT_PUBLIC_ prefix — meaning the key was baked into the public JavaScript bundle.
Why this matters: The service-role key bypasses all RLS. Anyone with the anon key plus the service-role key can read, write, and delete everything in your database. This isn't a theoretical risk — it's a one-line curl to dump every table.
~25% had at least one policy open to anon
A quarter of the scanned repos had at least one RLS policy with USING(true) and TO anon (or no TO clause, which defaults to public). The most common pattern:
-- "Login helper" that opens a whole table
CREATE POLICY "email_check" ON public.staff
FOR SELECT TO anon
USING (true);
The developer needed "does this email exist" on the login page. The policy works. It also exposes every column of every row to anyone with the anon key.
~10% had write-all policies for authenticated
One in ten had a policy like:
CREATE POLICY "allow_writes" ON public.notes
FOR ALL TO authenticated
USING (true)
WITH CHECK (true);
Any logged-in user can write any row. In a multi-user app, this means user A can modify user B's data. In a multi-tenant app, this means tenant A can access tenant B's data.
The cleanest apps had one thing in common
The apps that passed with zero findings all had:
- Explicit row predicates in every policy (
auth.uid() = user_id) - No
TO anongrants on user-data tables - No
.envfiles committed (or only placeholders)
None of this is advanced. It's just doing the RLS work instead of skipping it.
The three patterns that keep appearing
Pattern 1: The Login Helper
Intent: Check if an email exists on the login page.
Bug: Opens the entire table to the anon key.
Fix: Don't open the table — wrap the check in a SECURITY DEFINER function:
CREATE OR REPLACE FUNCTION public.email_exists(email TEXT)
RETURNS BOOLEAN LANGUAGE SQL SECURITY DEFINER SET search_path = public AS $$
SELECT EXISTS(SELECT 1 FROM public.users WHERE users.email = email_exists.email);
$$;
Pattern 2: The Missing TO Clause
Intent: "Users can read all profiles."
Bug: No TO clause means the policy applies to public, which includes anon.
Fix: Add TO authenticated to every policy.
Pattern 3: The VITE_ Prefix
Intent: Environment variable for server-side API access.
Bug: VITE_ or NEXT_PUBLIC_ prefix means the variable is baked into the public JS bundle.
Fix: The service-role key exists in server-side env only. Check with:
grep -r "VITE_.*SERVICE\|NEXT_PUBLIC_.*SERVICE" .
What you should do right now
If you have a Supabase app in production:
- Run this query (30 seconds):
SELECT tablename, policyname, roles, qual
FROM pg_policies
WHERE roles::text IN ('{anon}', '{public}')
AND qual = 'true';
- Check for committed secrets (30 seconds):
grep -r "sb_secret_\|SERVICE_ROLE" --include="*.env*" .
- Or use the free scanner — it does both checks on any public GitHub repo, entirely in your browser: rls.cenkkurtoglu.com
The bigger picture
Every one of these findings was in a real, shipped app with real users. Not demos, not tutorials — actual products. The gap isn't knowledge (most developers know RLS matters). The gap is review — nobody goes back and checks their policies after the initial implementation.
That's the whole reason I built the scanner and the audit service. The 30-second query above catches the most common patterns. The full audit catches everything else.
All findings are from public repositories. No databases were accessed. If you found an issue in your own app from this article — fix the committed keys first, then the RLS, then the policies.
Top comments (0)