Public scans show 11% of vibe-coded apps leak Supabase keys. Here are the 6 RLS misconfigs behind 90% of them, with real SQL examples and the fixes.
When we audit a Supabase backend an AI coder generated, the same six Row Level Security failures show up over and over. Public scanners agree. SupaExplorer's January 2026 sweep of 20,000+ indie launch URLs found 11% leaking Supabase credentials in their frontend bundle. A 2026 audit of five major AI app builders flagged sensitive-data exposure in over 40% of apps. CVE-2025-48757 alone covered 170 vulnerable showcased apps. Here's what we keep finding.
The AI generated CREATE TABLE statements and skipped ALTER TABLE … ENABLE ROW LEVEL SECURITY. PostgREST then serves every row to anyone holding the public anon key. One independent scan of 50 AI-generated apps in early 2026 found 89% had at least one fully exposed table.
CREATE TABLE public.user_profiles (
id uuid PRIMARY KEY,
user_id uuid REFERENCES auth.users,
email text,
stripe_customer_id text
);
-- No ALTER TABLE ... ENABLE ROW LEVEL SECURITY. None.This is RLS-in-name-only. USING (auth.role() = 'authenticated') says "if you're logged in, read all rows," and that includes every other user's data. Supabase's Security Advisor flags it (rule 0024). Most teams see a green "RLS Enabled" badge in the dashboard and call it done.
-- Looks fine in the dashboard but it isn't
CREATE POLICY "users can read" ON public.user_data
FOR SELECT USING (auth.role() = 'authenticated');
-- What it should be:
CREATE POLICY "users can read" ON public.user_data
FOR SELECT TO authenticated
USING ((select auth.uid()) = user_id);Environment variables get crossed and SUPABASE_SERVICE_ROLE_KEY ships as VITE_* or NEXT_PUBLIC_*. Service role bypasses RLS entirely. Full read/write across every table. The Moltbook incident on January 31, 2026 leaked 1.5M API tokens via this exact mistake. The Supabase MCP variant, where coding agents hold service_role and ingest untrusted user input, was demonstrated in mid-2025 and remains an open class of risk.
SELECT is correctly scoped to auth.uid(). Then the UPDATE policy lets users modify their own row, including columns like role or subscription_tier. The 18,697-user breach disclosed February 27, 2026 traced to inverted policy logic produced by an AI optimizing for "code that runs."
-- Missing WITH CHECK = users can flip their own role to admin
CREATE POLICY "update own profile" ON public.profiles
FOR UPDATE TO authenticated
USING ((select auth.uid()) = user_id);
-- Add WITH CHECK to enforce ownership on write,
-- then revoke UPDATE on the role column at the table level.Postgres views run with the privileges of their owner, usually postgres. AI-generated migrations expose convenience views like user_dashboard_data without security_invoker = on, and PostgREST serves them to anyone with the anon key. Same risk for SECURITY DEFINER functions sitting in the public schema.
ALTER VIEW public.user_dashboard_data
SET (security_invoker = true);Buckets default to convenient over correct. We see public buckets with sensitive uploads, or private buckets with USING (true) policies. User-folder isolation via (storage.foldername(name))[1] = (select auth.uid())::text is almost never present on a first audit.
When all six show up in one project, which is the median case and not the worst, fixing them is roughly a week of work. Shipping with them is roughly the cost of a CVE.