Afterprod
Security· 8 min read· By the Afterprod team

Is your Supabase data public? How to check Row Level Security in a Lovable or Bolt app

If your app runs on Supabase and a table in the public schema has Row Level Security switched off, or has a policy that lets everyone through, anyone who opens your site can read that table, and often change it, using the key your front end already ships. You can check in about ten minutes, and fix it without rebuilding anything.

Short answer
  • Your Supabase public key is meant to be public. Row Level Security (RLS) is the only thing standing between it and your data.
  • Run two SQL queries to list tables without RLS and policies that allow everyone.
  • Confirm from outside with one curl request against your own app while logged out.
  • Fix with enable row level security plus one owner-based policy per access pattern.

Why AI-built apps hit this so often

Supabase lets the browser talk to your database directly. The browser sends the project's public key (the anon key, or a newer sb_publishable_ key), and Postgres decides what that request may see using RLS policies. That design is sound, but it puts all of the protection in the policies.

AI app builders create tables quickly and do not always finish the policies. Sometimes RLS is never switched on. Sometimes a policy is added to make an error go away, and the quickest policy that works is one that allows everyone. The app looks fine in the browser because the screens only show the right rows. The database, though, will hand any row to anyone who asks.

This is not hypothetical. In 2025 a researcher scanned 1,645 apps from Lovable's public showcase and found 170 with tables readable or writable using only the public key, about one in ten. It was published as CVE-2025-48757, and the exposed data included emails, home addresses and third-party API keys (write-up).

Check 1: list tables with RLS switched off

Open your Supabase project, go to the SQL Editor, and run:

select tablename, rowsecurity
from pg_tables
where schemaname = 'public'
order by rowsecurity, tablename;

Any row where rowsecurity is false is a table the public role can usually read and write with your public key. In the Table Editor these tables carry an Unrestricted label, and the Security Advisor (under Advisors in the dashboard) lists them as "RLS disabled in public".

Tables that hold nothing private, such as a list of pricing plans, can be fine without RLS, but they should be read-only. Everything else needs RLS on.

Check 2: find policies that only look safe

RLS switched on is necessary, not sufficient. List your policies:

select tablename, policyname, cmd, roles, qual, with_check
from pg_policies
where schemaname = 'public'
order by tablename;

Read each row and look for these patterns:

  • qual or with_check is just true. That policy allows every row to everyone in roles.
  • roles is {public} or {anon} on a table with user data. Logged-out visitors should rarely see private rows.
  • An update policy without with_check. A user may be able to change a row's owner to someone else.
  • A policy that checks a column the user can set, such as role = 'admin' on a profile the user can edit. Anyone can promote themselves.

Check 3: views and storage

Views run with their owner's rights by default, which means they skip RLS on the tables underneath. On Postgres 15 and later, create them with with (security_invoker = true) so the caller's policies apply. The Security Advisor flags views that bypass RLS.

Storage buckets marked Public serve any file to anyone who has the URL, and URLs are often guessable. Keep invoices, IDs and uploads in private buckets with policies on storage.objects, and hand out signed URLs.

Check 4: confirm from outside, the way an attacker would

Do this only on an app you own or have permission to test. Open your site logged out, open the browser's developer tools, and find a request to your-project.supabase.co/rest/v1/. Copy the apikey header. Then, from a terminal:

curl "https://YOUR-PROJECT.supabase.co/rest/v1/profiles?select=*&limit=5" \
  -H "apikey: YOUR_PUBLIC_KEY"

Swap profiles for each table that holds user data. If rows come back while you are logged out, anyone on the internet can get them too. An empty [] means the table is empty or RLS is filtering, which is a good sign, but still read the policies from check 2.

While the developer tools are open, search your JavaScript bundle for sb_secret_ and service_role. Either one in the browser is a full bypass of RLS, and that key must be rotated.

The fix, without a rewrite

For a typical table where each row belongs to one user:

alter table public.profiles enable row level security;

create policy "Users read their own profile"
on public.profiles for select
to authenticated
using ( (select auth.uid()) = id );

create policy "Users update their own profile"
on public.profiles for update
to authenticated
using ( (select auth.uid()) = id )
with check ( (select auth.uid()) = id );

Three details matter:

  1. Turning RLS on with no policies blocks everything. Expect a screen or two to go blank, then add one policy per real access pattern rather than one broad policy.
  2. Wrap auth.uid() in (select ...). Postgres evaluates it once per query instead of once per row, which Supabase recommends for performance.
  3. Index the column your policy checks (user_id, owner_id, org_id). Policies run on every query, so an unindexed one turns into a slow app as soon as the table grows.

Keep the AI from undoing it

The next prompt that adds a table can reopen the hole. Put a standing rule in your project instructions ("every new table enables RLS with owner-based policies; never use using (true) on user data"), keep your migrations in git so policy changes show up in review, and re-run the Security Advisor before each release.

RLS is the most common gap we find in AI-built apps, but it is rarely the only one. Our launch-ready checklist covers the rest.

Questions founders ask

Is the Supabase anon key a secret?

No. The anon key (or the newer sb_publishable_ key) is designed to ship in your front end. It identifies your project; it does not protect your data. Row Level Security policies do that. The service_role or sb_secret_ key is the one that must never reach the browser.

Will turning on Row Level Security break my app?

It can. With RLS on and no policies, the public and logged-in roles can read nothing, so screens that relied on open tables go blank. That is expected. Add one policy per real access pattern, test each screen, then ship.

Does my AI builder's security scan catch this?

Built-in scanners usually flag tables with RLS switched off. A policy that exists but allows everyone, such as using (true), can still pass. Read your policies, and test from outside with the public key.

How do I stop the AI from undoing the fix?

Add a standing rule to your project instructions: every new table enables RLS with owner-based policies. Keep migrations in git so you can see policy changes in review, and re-run the Security Advisor before each release.

Keep reading