If you built an app with Lovable, Bolt or Replit on top of Supabase, open a second browser, sign up as a new user, and look at the dashboard. If you see the first user's records, this post is for you. If the list is empty but you know there are rows, this post is also for you: both symptoms have the same cause.
Why this keeps happening
Your front-end talks to Supabase with the anon key. That key is public by design: anyone can read it from the network tab and call your database directly with curl. The only thing that decides what they get back is Row Level Security (RLS): per-table policies that Postgres evaluates for every query.
AI builders generate the table and the UI first. RLS comes later, if at all, and when a policy error shows up during a "Try to fix" loop the easiest way to make the error disappear is a policy that says USING (true): everyone may read everything. The app works, the preview looks fine, and the data is public.
The numbers are not small. Symbiotic Security scanned 1,072 Supabase-backed vibe-coded apps in June 2026 and found at least one finding in 98% of them, with a critical flaw in 16%. The RLS gap tracked as CVE-2025-48757 affected roughly 170 of 1,645 scanned Lovable apps.
Diagnose it in two minutes
Open Supabase → SQL Editor and run this. It lists tables with RLS off, tables with RLS on but no policies, and policies that let everyone in.
-- tables with RLS off
select c.relname as table_name, 'RLS OFF' as finding
from pg_class c join pg_namespace n on n.oid = c.relnamespace
where n.nspname = 'public' and c.relkind in ('r','p') and not c.relrowsecurity;
-- RLS on, zero policies (this is the "empty list" symptom)
select c.relname, 'RLS ON, NO POLICIES'
from pg_class c join pg_namespace n on n.oid = c.relnamespace
where n.nspname = 'public' and c.relkind in ('r','p') and c.relrowsecurity
and not exists (select 1 from pg_policy p where p.polrelid = c.oid);
-- policies that are literally "true" (this is the "everyone sees everything" symptom)
select tablename, policyname, cmd, qual, with_check
from pg_policies
where schemaname = 'public'
and (coalesce(qual,'') in ('true','(true)') or coalesce(with_check,'') in ('true','(true)'));
One warning: the SQL editor runs as postgres, which bypasses RLS. A query that works there proves nothing about what your users see. Test as a user:
begin;
select set_config('request.jwt.claims', '{"sub":"<a real user uuid>","role":"authenticated"}', true);
set local role authenticated;
select count(*) from public.projects;
rollback;
The fix for user-owned data
For any table where rows belong to one user (projects, notes, orders, settings):
alter table public.projects
add column if not exists user_id uuid not null default auth.uid()
references auth.users (id) on delete cascade;
alter table public.projects enable row level security;
drop policy if exists "Enable read access for all users" on public.projects;
create policy "owner select" on public.projects for select to authenticated
using (user_id = (select auth.uid()));
create policy "owner insert" on public.projects for insert to authenticated
with check (user_id = (select auth.uid()));
create policy "owner update" on public.projects for update to authenticated
using (user_id = (select auth.uid())) with check (user_id = (select auth.uid()));
create policy "owner delete" on public.projects for delete to authenticated
using (user_id = (select auth.uid()));
Three details that matter:
-
default auth.uid()means the builder's generated insert does not need to senduser_id. Forgetting it is the number one cause ofnew row violates row-level security policy. -
(select auth.uid())instead of bareauth.uid()lets Postgres evaluate it once per query. On a table with 100k rows that is the difference between 50 ms and several seconds. - Policies are per operation. "Reading works, saving fails" is almost always a missing INSERT policy or a missing
WITH CHECK.
Public data is fine, as long as it is deliberate
A blog or a product catalog should be readable by everyone. Make it explicit and keep writes to the owner:
create policy "anyone reads published" on public.posts for select to anon, authenticated
using (published = true);
create policy "author writes" on public.posts for all to authenticated
using (author_id = (select auth.uid())) with check (author_id = (select auth.uid()));
Teams and the "infinite recursion" error
If access depends on membership (workspace members see workspace documents), the naive policy queries the membership table, whose policy queries the membership table, and Postgres gives up with infinite recursion detected in policy. The way out is a security definer function:
create or replace function public.is_workspace_member(ws uuid)
returns boolean language sql security definer set search_path = public stable as $$
select exists (select 1 from public.workspace_members m
where m.workspace_id = ws and m.user_id = auth.uid());
$$;
create policy "members read" on public.documents for select to authenticated
using (public.is_workspace_member(workspace_id));
Verify, don't assume
- Run the audit query again. Everything flagged is either fixed or documented as intentionally public.
- Two browsers, two users. Create a record as A; B must not see it in the UI and must not get it from the API:
curl "https://<project>.supabase.co/rest/v1/projects?select=*" \
-H "apikey: <anon key>" -H "Authorization: Bearer <user B token>"
Expected: [].
- Re-run your builder's security scan.
Tell the builder what you did
If you ran SQL directly in Supabase, paste it into Lovable or Bolt as "this migration was applied" so the generated types and your next prompts stay in sync. Otherwise the next "add a feature" prompt may happily recreate the permissive policy.
The full version of this fix, plus four others (auth redirects, React render loops, Stripe edge functions, publish and custom domains) and a 32-point launch checklist, lives in an open repo: github.com/yurij12211-ship-it/ai-built-app-fixes. If you would rather have someone look at your app, there is a free 24-hour diagnosis linked from the README.
Top comments (0)