I've been working on an open-source security auditor for Supabase projects for the last few weeks (supabase-security on npm, MIT licensed, ~4k weekly downloads). Probes anonymously to find RLS gaps, public storage buckets, SECURITY DEFINER functions exposed to anon, that kind of thing.
This morning I shipped a new feature: a --discover mode. Keyless — it walks your local repo, pulls every table / RPC / bucket reference from .from() / .rpc() / .storage.from() call sites, then probes only the surface your app actually uses with the public anon key. No PAT, no admin token, no signup.
Before announcing it, I wanted to run it against my own production CRM as the final QA test. Worst case it finds zero issues and I look smart.
It found 14 critical leaks
-
7 tables that returned actual rows to anonymous callers (
couriers,bot_knowledge_base,product_variants,zone_stats, etc — internal business data) -
6 SECURITY DEFINER functions that anyone with my public anon key could execute (
get_dashboard_stats,get_courier_stats,get_historical_daily_stats— basically the entire reporting surface, all callable without auth) - 2 storage buckets that anon could list
The pattern
Every single one had RLS technically "enabled" on the Supabase dashboard. Every SELECT policy was USING (true) applied to roles {-} (which in Postgres means "all roles", anon included).
-- What I found via pg_policy:
polname | polcmd | roles | using_expr
couriers_select | r | {-} | true
bot_knowledge_base_select | r | {-} | true
product_variants_select | r | {-} | true
-- ...and 4 more
Server-side reads use service_role (which bypasses RLS), so nothing internal broke. Client-side reads were never wired up either. The only callers were... anyone on the internet, since the anon key sits in every JS bundle the moment someone opens devtools.
The fix
DROP POLICY IF EXISTS couriers_select ON public.couriers;
CREATE POLICY couriers_select ON public.couriers
FOR SELECT TO authenticated USING (true);
-- repeat 6x
REVOKE EXECUTE ON FUNCTION public.get_dashboard_stats() FROM anon, public;
GRANT EXECUTE ON FUNCTION public.get_dashboard_stats() TO authenticated;
-- repeat 5x
Applied via the Management API since local migrations were out of sync with prod. Re-ran the audit. 14 → 0.
What I keep thinking about
The dashboard "RLS enabled" checkmark is misleading. It tells you nothing about who can actually read what. Until you fire an HTTP request with the public anon key and inspect the response, you're guessing.
Tools that don't catch yourself aren't done. I had written the check for this exact pattern six weeks ago, and was still vulnerable. Static analysis of migrations alone misses it — the SQL looks fine. You need to actually probe.
The under-priced risk on Supabase right now is RPC functions with SECURITY DEFINER + EXECUTE granted to anon. Most scanners don't look at the RPC surface. Mine had 6 open. Several leaked admin-flavored data.
Try it on yours
npx supabase-security@latest --discover .
MIT licensed, findings stay on your machine, no signup. Repo on GitHub.
First time the tool felt like it was actually pulling its weight was when it pointed back at me. Mildly humbling.
If you ship on Supabase, please run this. The 22% figure I quote in the longer blog post about scanning 100 random projects is real, and I now know first-hand that "I'm careful with RLS" isn't a defense.
Top comments (0)