Supabase row-level security mistakes in AI-built apps

The Supabase row-level security (RLS) mistakes that turn up most in AI-built apps are tables with RLS off, policies that allow everyone, policies that allow any signed-in user, and authorization based on data users can edit. Most share a cause. When a query fails with a permission error, the smallest change that makes the app work is a looser policy, and an AI agent asked to fix "my data doesn't load" will often make exactly that change.

These mistakes matter more in apps from Lovable, Bolt and similar tools because those apps usually have no server code between the browser and the database. The browser queries Supabase with a public key, and RLS policies decide what each request can reach. A missing policy exposes the whole table, not one endpoint.

1. RLS switched off

Tables created in the Supabase dashboard have RLS on by default. Tables created by SQL migrations, which is how AI builders create them, have RLS only if the migration enables it. A table without it can be read and changed by anyone holding the public key. This was the cause of CVE-2025-48757, in which a researcher found exposed data in 170 of the 1,645 Lovable apps he checked.

To check, list the public tables where rowsecurity is false in pg_tables, or open Supabase's Security Advisor.

2. A policy that allows everyone

using (true) on a select policy makes a table public. That is correct for data you would publish anyway, such as blog posts or a product catalog. On anything else it is a leak, and it often arrives as a fix for an empty page. Search your migrations for using (true) and with check (true) and confirm each one is intended.

3. Treating "signed in" as "allowed"

A policy like using (auth.role() = 'authenticated') limits a table to signed-in users. In an app with open sign-up, anyone can become a signed-in user, create an account and read every row. A policy that protects per-user data compares the row's owner with the caller, for example (select auth.uid()) = user_id.

4. Inserts that don't check the owner

An insert policy with with check (true) lets a user create rows that belong to someone else, such as a message attributed to another user or an order on another customer's account. Each insert policy needs a with check that ties the new row to the caller.

5. Expecting RLS to protect columns

RLS controls which rows a user can touch. It doesn't control columns. If users can update their own profiles row and that row includes is_admin, plan or credits, they can change those fields from the browser. Move privileged fields to a table users can't write to, or limit which columns the authenticated role may update:

revoke update on public.profiles from authenticated;
grant update (display_name, avatar_url) on public.profiles to authenticated;

6. Authorization from user metadata

Users can edit their own user_metadata (stored as raw_user_meta_data). A policy or function that reads a role from it lets users make themselves admins. Supabase recommends app_metadata for authorization data, because only your server can change it. A roles table protected by RLS also works.

7. Views and functions that bypass RLS

  • Views run with the permissions of their creator, usually postgres, so by default they ignore RLS on the tables underneath. On Postgres 15 and later, create them with (security_invoker = true).
  • Security definer functions run with their creator's privileges. If one is in an exposed schema, anyone can call it at /rest/v1/rpc/<name>. In the CVE-2025-48757 research, one app's RPC function returned a Gemini API key to anyone who called it. Keep such functions out of exposed schemas, or check the caller inside them.

8. A secret key in the browser

The Supabase secret key (sb_secret_...) and the legacy service_role key bypass RLS completely, so if either reaches front-end code, none of the policies above apply. Only the publishable key (or the legacy anon key) belongs in the browser. Which keys are which, and what to do if one leaks, is covered in step 4 of our Lovable launch checklist.

Testing your policies

  1. Create two test users, A and B, with data in each.
  2. Signed out, request each table with only the public key. Nothing private should come back.
  3. Signed in as B, try to read, update and delete A's rows, and to insert a row owned by A.
  4. As a normal user, try to update your own is_admin, plan or similar fields.
  5. Signed out, call each function under /rest/v1/rpc/.

Repeat after any change to the schema or policies. In an app an AI agent is still building, that can be several times a day.

How Cyfendo tests RLS

When a project's Supabase migration files are part of the code it checks, Cyfendo starts a stand-in Supabase in an isolated sandbox, applies the project's RLS settings and policies, and creates two test users. It then makes the requests from steps 2 and 3: reading and changing rows with only the public key, and reading or changing one user's rows as the other. Problems it reproduces are reported with fix steps. It understands owner checks, true and auth.role() policies; any policy it can't parse is treated as denying access, so it doesn't report leaks it hasn't reproduced. Connected to Bolt or Lovable, it runs when the agent changes data access.

See what Cyfendo finds in your code.

Free for apps up to 10,000 lines of code. No credit card.