DEV Community

Yurij
Yurij

Posted on Edited on

Your Lovable app shows everyone's data. Here's the 10-minute Supabase RLS fix

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)'));
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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()));
Enter fullscreen mode Exit fullscreen mode

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, so auth.uid() is null for every old row and Postgres refuses with column "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 send user_id. Forgetting it is the number one cause of new row violates row-level security policy.
  • (select auth.uid()) instead of bare auth.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()));
Enter fullscreen mode Exit fullscreen mode

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));
Enter fullscreen mode Exit fullscreen mode

Verify, don't assume

  1. Run the audit query again. Everything flagged is either fixed or documented as intentionally public.
  2. 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>"
Enter fullscreen mode Exit fullscreen mode

Expected: [].

  1. 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)

Collapse
 
sunnatulla_dev profile image
Sunnatulla •

One thing to watch in the "user-owned data" step: if projects already has rows, that one-step add column ... not null default auth.uid() fails. In the SQL editor there's no JWT, so auth.uid() is null for every existing row and Postgres stops with column "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:

alter table public.projects
  add column if not exists user_id uuid references auth.users (id) on delete cascade;

-- give old rows an owner (whatever rule fits: created_by, email match, or one admin)
update public.projects set user_id = '<owner uuid>' where user_id is null;

alter table public.projects
  alter column user_id set default auth.uid(),
  alter column user_id set not null;
Enter fullscreen mode Exit fullscreen mode

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.

Collapse
 
yurij_builds profile image
Yurij •

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.