DEV Community

Cover image for Why AI-Generated Supabase Apps Leak User Data, and How to Audit Row-Level Security Before Launch
fixier
fixier

Posted on Edited on Originally published at fixier.co

Why AI-Generated Supabase Apps Leak User Data, and How to Audit Row-Level Security Before Launch

AI app builders are very good at producing screens, forms and working demos. They are far less reliable at the part nobody sees: who is allowed to read which row. In a typical Lovable or Bolt app backed by Supabase, the browser talks almost directly to Postgres. That means Row-Level Security (RLS) is not one security layer among many. It is the only thing standing between one user and everyone else’s data.
The anon key is public by design
Founders often panic when they see the Supabase anon key in their browser bundle. That key is meant to be public. The real question is what that key is allowed to do once someone copies it. If RLS is off or too loose, anyone can open the browser console and query your tables with the same client your app uses.

Six RLS failures that show up again and again in generated code

  1. RLS was never enabled. Tables created through SQL migrations do not have RLS switched on automatically. A generated migration that creates a table but skips alter table ... enable row level security ships an open table.
  2. Policies written as using (true). The AI makes the “permission denied” error go away by adding a policy that allows everything. It works, and it also exposes every row to every logged-in user (or to anonymous visitors, if the policy targets anon).
  3. INSERT policies that do not bind ownership. A policy with with check (true) lets a user insert a row with someone else’s user_id. Pair it with a permissive select policy and you have impersonation. Ownership must be enforced in the check clause: alter table orders enable row level security;
create policy orders_select_own on orders
  for select to authenticated
  using ((select auth.uid()) = user_id);

create policy orders_insert_own on orders
  for insert to authenticated
  with check ((select auth.uid()) = user_id);
Wrapping auth.uid() in a subselect also lets Postgres evaluate it once per query instead of once per row, which matters as tables grow.
Enter fullscreen mode Exit fullscreen mode
  1. Authorizing on user_metadata. Claims inside user_metadata can be edited by the user. A policy such as “role is admin in user metadata” can be satisfied by the user themselves. Authorization data belongs in app_metadata or in a server-controlled table.
  2. Views and functions that bypass RLS. A view runs with the privileges of its owner by default, so it can return rows that the underlying table’s policies would block. On Postgres 15 and later, create views with security_invoker = true. Treat every security definer function as a potential policy bypass and review its inputs carefully.
  3. Storage buckets and the service role key. Public buckets and missing policies on storage.objects leak uploads such as invoices and IDs. Worse, generated code sometimes uses the service role key in client-side code. That key bypasses RLS entirely and must live only on the server.

A practical pre-launch audit

  1. Inventory tables without RLS. Start with a query that lists them: select tablename from pg_tables where schemaname = 'public' and rowsecurity = false;
  2. Read every policy as an attacker. For each one, ask: who can run this, what rows can they touch, and can they change ownership columns?
  3. Test with two real users. Sign in as user A, create data, then try to read, update and delete it as user B. Repeat as an anonymous visitor. This catches what policy review misses.
  4. Check the exposed surface. Search the built JavaScript for service_role, review RPC functions callable by anon, and confirm storage bucket visibility.
  5. Use the platform’s own advisors. Supabase’s security advisor flags several of these issues, but it cannot know your business rules. A table can be correctly “protected” and still allow the wrong person to see it.

Make it permanent
RLS bugs return whenever an AI tool regenerates a migration. Put policy tests in CI: create two test users, run queries under each role, and assert that cross-user access fails. Supabase supports pgTAP tests through its CLI, so a failing test can block the deploy that would have reopened a table.

Takeaway
If your app has user accounts and was built mostly by prompts, treat RLS as the first thing to verify, not the last. For a deeper walkthrough, see Fixier’s Supabase RLS checklist and the guide on users seeing each other’s data.
Jiya works at Fixier, a team that audits, fixes and supports apps built with Lovable, Bolt, v0 and Cursor. Learn more at fixier.co.

Top comments (0)