We found 2 critical RLS vulnerabilities — in our own app
Vetora exists to find the vulnerabilities that "vibe coding" leaves behind in apps built with Bolt, Lovable, v0 or Cursor — exposed keys, misconfigured RLS, committed secrets. Ironically, while building Vetora itself on Supabase, we let through exactly that kind of vulnerability in our own database schema. Here's what happened, why, and how we fixed it.
The context
Like many AI-generated apps, Vetora has no user accounts: scans are created and viewed by URL, no login. The initial migration (generated by the scaffold's AI assistant) created two tables, scans and findings, with Row Level Security enabled — nothing unusual so far. The problem was in the details of the policies.
Vulnerability 1: unlocking a paid report without paying
The scans table has a paid column that determines whether the full report (49€) is unlocked. The generated UPDATE policy looked like this:
CREATE POLICY "anon_update_scans" ON scans FOR UPDATE
TO anon, authenticated USING (true) WITH CHECK (true);Supabase's anon key is public by design — it's sent to every visitor in the site's JavaScript bundle (NEXT_PUBLIC_SUPABASE_ANON_KEY). With an UPDATE policy set to USING (true), anyone could open the browser console and type:
supabase.from('scans').update({ paid: true }).eq('id', scanId)Result: the full report unlocked, no Stripe involved, no money paid.
Vulnerability 2: forging or wiping scan results
Same problem on the findings table, which stores detected vulnerabilities: open INSERT and UPDATE policies for the anon role. Anyone could insert fake vulnerabilities on an existing scan, or overwrite the descriptions and fix instructions of a real result — enough to sabotage a report's credibility, in either direction.
The root cause: unnecessary policies
Digging further, we realized these write policies never had a reason to exist. Every real write in the app — creating a scan, inserting findings, updating paid after a genuine Stripe payment — goes through a server-side service_role client, which bypasses RLS entirely. The policies open to anon served no legitimate purpose: they were purely an attack surface, never used by the code, never tested, never noticed.
It's a classic trap when an AI generates a full Supabase schema in one pass: it grants broad access by default "just in case" the frontend needs it, without checking whether the calling code actually uses it.
The fix
We dropped every INSERT/UPDATE policy for anon and authenticated on both tables, keeping only the public SELECT (intentional: reports are shared by URL, no account). Nothing broke, since nothing depended on them:
DROP POLICY IF EXISTS "anon_insert_scans" ON scans;
DROP POLICY IF EXISTS "anon_update_scans" ON scans;
DROP POLICY IF EXISTS "anon_insert_findings" ON findings;
DROP POLICY IF EXISTS "anon_update_findings" ON findings;Three questions to ask about your own RLS policies
- Do my writes already go through a server-side
service_roleclient? If so, do my INSERT/UPDATE policies open toanonactually do anything? - Is a column like
paid,roleoris_admindirectly writable from the client, without a server-side check? - Does
USING (true)appear on a write policy, not just a read one?
If you answered "yes" to any of these, you probably have the same problem we did. Scan your app to check.