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):
-- 1. add the owner column, nullable for now
alter table public.projects
add column if not exists user_id uuid references auth.users (id) on delete cascade;
-- 2. give existing rows an owner BEFORE the policies go live
-- (pick the rule that fits: a created_by column, an email match, or one admin)
update public.projects set user_id = '<owner uuid>' where user_id is null;
-- 3. now make it required, defaulting to the signed-in user
alter table public.projects
alter column user_id set default auth.uid(),
alter column user_id set not null;
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()));
Four details that matter:
- Do not add the column as
not null default auth.uid()in one step on a table that already has rows. The SQL editor has no signed-in user, soauth.uid()is null for every old row and Postgres refuses withcolumn "user_id" contains null values. And if you skip the backfill, the owner policies hide old rows from everyone, which looks like data loss. (Thanks to Sunnatulla for catching this in the comments.) -
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 (2)
One thing to watch in the "user-owned data" step: if
projectsalready has rows, that one-stepadd column ... not null default auth.uid()fails. In the SQL editor there's no JWT, soauth.uid()is null for every existing row and Postgres stops withcolumn "user_id" of relation "projects" contains null values. That's exactly the case of someone with a live Lovable app and real data.I ran it against a table with two rows to be sure. Splitting it works:
Worth deciding the backfill rule before enabling the owner policies, otherwise those old rows just disappear for everyone, which looks like data loss to the user.
You're right, and thanks for actually running it. With existing rows the one-step version fails exactly as you describe, and skipping the backfill makes old rows vanish once the owner policies are on, which is worse than the error. I've updated the post and the repo to your three-step order: add nullable, backfill, then set default and not null. Credited you in the post.