August 16, 2026

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_role client? If so, do my INSERT/UPDATE policies open to anon actually do anything?
  • Is a column like paid, role or is_admin directly 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.