DEV Community

Cenk KURTOĞLU
Cenk KURTOĞLU

Posted on

You shipped a Supabase app with AI. Here are the 4 security holes it probably has (and the 5-minute fixes)

I build security tooling for Supabase, and I keep finding the same four holes in apps that shipped fast with Lovable, Bolt, v0, Cursor, or Replit. Not careless apps — good ones. Real users, real revenue, clean UIs. The people who built them aren't sloppy; they shipped a real product in a weekend, which is genuinely hard and genuinely good. But AI codegen optimizes for it works, and "it works" and "it's locked down" are two different tests. The second one rarely runs on its own.

So let me run it with you — worst first, then the thing everyone panics about but shouldn't, then the two RLS holes that actually bite, then a query you can run against your own project in 60 seconds.

1. A service_role key or DB password committed to the repo

This is the one that can end the whole app, so it goes first. Your service_role (secret) key has BYPASSRLS — it ignores every policy you ever wrote and has full read/write over your entire database. A Postgres connection string with the password in it is the same deal. When an AI tool scaffolds a backend it often writes both into .env, and if .env isn't ignored, one git push puts your master key out there.

Two traps people miss:

  • Private repos aren't safe either. Anything pushed while a repo was public is scraped almost immediately; flipping it to private afterward doesn't recall it. And collaborators, later public-ization, and breaches all expose a private repo too.
  • Deleting the file doesn't fix it. The secret still lives in your git history and in every clone and fork already out there.

Fix, in order — rotation is what actually saves you; the history scrub is hygiene:

# 1. Rotate FIRST, in the Supabase dashboard:
#    Project Settings -> API -> roll the service_role / secret key
#    Project Settings -> Database -> reset the database password
Enter fullscreen mode Exit fullscreen mode

One caveat on rotation: check which key model your project is on. Newer projects use sb_secret_ / sb_publishable_ keys that can be revoked individually. Older projects use the legacy JWT-based keys, where regenerating the JWT secret rotates the anon key at the same time and drops every logged-in session — so plan for that.

# 2. Then purge it from history. Run this on a FRESH full clone:
pip install git-filter-repo
git filter-repo --path .env --invert-paths --force
git remote add origin <your-repo-url>   # filter-repo drops the remote by design
git push --force --all
git push --force --tags                 # a key can sit in a tagged commit too
Enter fullscreen mode Exit fullscreen mode

History rewriting doesn't reach existing forks, open PRs, or GitHub's caches — which is exactly why step 1 is the real mitigation. Going forward: service_role lives only on a server or in Edge Function secrets, never in the browser bundle, never in the repo. Add .env and .env.* to .gitignore today.

2. Relax — your anon key is supposed to be public

Half the panic I see is misdirected here. The anon key (Supabase now also calls it the publishable key) is public by design. It ships in your client bundle on purpose. You do not need to rotate it, and finding it in your JS is not a breach. I've watched people burn a weekend "fixing" this while the real hole sat untouched.

The anon key isn't a password — it's a name tag that says "unauthenticated visitor." It's low-privilege on its own; what it can actually touch is decided by table grants plus Row Level Security. Which is exactly why the next two holes are the ones that matter.

3. RLS off, or a policy that says USING (true)

On most Supabase projects, tables in the public schema get default grants for the anon role and are exposed through the Data API. So if RLS is off, the anon key reads every row. A policy of FOR SELECT USING (true) with no TO clause is the same thing — it applies to everyone, anon included.

Turn RLS on and scope reads to the owner:

alter table public.profiles enable row level security;

create policy "read own profile"
  on public.profiles
  for select
  to authenticated
  using (auth.uid() = user_id);
Enter fullscreen mode Exit fullscreen mode

enable row level security with no policy means deny-all — you then add back exactly what each role should see. The to authenticated keeps anon out entirely.

4. The write leak: reads and writes use different rules

This is the subtle one, and the one AI gets wrong most. Two different expressions guard a table:

  • USING filters which existing rows you can see or touch (SELECT, UPDATE, DELETE).
  • WITH CHECK validates the new row's values (INSERT, and the post-update row).

INSERT has WITH CHECK only. So this — which looks locked down — lets anyone signed in insert a row as any user:

-- BAD: new row can carry any user_id
create policy "add" on public.posts
  for insert to authenticated
  with check (true);
Enter fullscreen mode Exit fullscreen mode
-- GOOD: the new row must belong to the caller
create policy "add own" on public.posts
  for insert to authenticated
  with check (auth.uid() = user_id);
Enter fullscreen mode Exit fullscreen mode

Three things worth memorizing:

  • FOR ALL USING (true) with no WITH CHECK reuses that true as the write check too — reads and writes are open.
  • A FOR UPDATE ... USING (auth.uid() = user_id) with no WITH CHECK is actually safe: Postgres reuses USING as the check, so an attempt to hand a row to another user bounces.
  • Permissive policies OR together — one true anywhere reopens that command for every role that policy applies to, and a tight policy can't claw it back.

The 60-second self-check

Run this in the Supabase SQL editor, against your live project:

-- 1. Tables with RLS off (ignore extension/reference tables like spatial_ref_sys):
select tablename
from pg_tables
where schemaname = 'public' and rowsecurity = false;

-- 2. anon/public policies that over-share reads or let anyone write:
select tablename, policyname, cmd, roles, qual, with_check
from pg_policies
where schemaname = 'public' and permissive = 'PERMISSIVE'
  and ('anon' = any(roles) or 'public' = any(roles))
  and (qual = 'true' or with_check = 'true');

-- 3. authenticated write policies open to any logged-in user (the cross-tenant leak):
select tablename, policyname, cmd, roles, with_check
from pg_policies
where schemaname = 'public' and permissive = 'PERMISSIVE'
  and 'authenticated' = any(roles)
  and cmd in ('INSERT', 'ALL')
  and (with_check = 'true' or with_check is null);
Enter fullscreen mode Exit fullscreen mode

A clean result lowers your risk but doesn't prove safety. These queries only match the literal string true, so functionally-open predicates like using (1=1) or using (user_id = user_id) slip through — eyeball anything with a suspiciously broad qual. And a for select to authenticated using (true) still lets every logged-in user read every tenant's rows, so give authenticated-scoped reads a second look too.

One honest caveat: RLS is often configured in the Supabase dashboard, not in committed SQL — so "there's no policy in my repo" proves nothing about production. The live query above is the only source of truth.


None of this makes you a worse builder for shipping fast. Shipping fast is the whole point; you just want the "locked down" test to run once too. Turn RLS on, scope to auth.uid(), keep service_role off the client, and let the anon key be exactly as public as it was always meant to be — and the fast path stays the safe one.

If you want to watch the RLS side of this fail and then a one-line policy change close it, I put a small reproducible project here: github.com/cekuu35/supabase-rls-leak-demo — it runs Postgres locally (PGlite, no Supabase account or credentials needed; npm ci && npm run test:ci), and the isolation tests flip from failing to passing once the right policies are in place. It also ships a free, SELECT-only audit/rls-audit.sql — nine catalog queries you can paste straight into the Supabase SQL editor.

If you'd rather hand someone a done-for-you checklist across a whole project, a Supabase RLS Audit Kit exists — but honestly, the repo and the query above catch most of what I find.

Top comments (0)