Why AI-Built Apps Break · For engineers

The key isn't the leak: how AI-built Supabase apps expose every row

Two incidents, one cause: the browser talks to the database, and Row Level Security is the only lock. We tested four ways it stays open on PostgreSQL 17.

Short answer The public Supabase key is meant to be in the browser. Data leaks when a table has Row Level Security off, a policy that allows everyone, or a view that bypasses it. Test with the public key as a stranger, not in the SQL editor.

On 31 January 2026, researchers at Wiz browsed Moltbook, a social network for AI agents, the way any visitor would. Within minutes they had read and write access to its production databaseSource 1: 1.5 million API tokens, 35,000 email addresses and private messages between agents. Eight months earlier, CVE-2025-48757 had described the same failure in apps generated by Lovable: unauthenticated attackers could read or write their database tablesSource 2.

Neither leak needed a stolen password or a clever exploit. Both used a key that was meant to be public.

§1

The key was never the secret

Supabase gives every project a key for the browser, and its documentation calls that key safe to put in a web page, an app or source codeSource 3. Finding it in a JavaScript bundle is not a finding.

What matters is where the key goes. An app built with Lovable or a similar tool usually has no server of its own: the page calls the database's REST API directly. Matt Palmer, who reported the CVE, describes those apps as calling the database from the browser with the public key and relying only on Row Level Security for privacySource 4. Row Level Security (RLS) is a Postgres feature: rules, called policies, that decide row by row what each request may read or change. In this design it is the only lock on the door.

Lovable disputes the CVE, saying each customer accepts responsibility for protecting their own app's dataSource 2. On paper that is fair. In practice the customer asked for a booking app, and the policy that carries the responsibility was written, or skipped, by the generator.

§2

Four ways to leave the door open, tested

We wanted numbers, not impressions. On 1 October 2026 we ran PostgreSQL 17.11 in Docker and set it up the way Supabase does: an anon role for requests that carry only the public key, an authenticated role for signed-in users, an auth.uid() function that returns the signed-in user's id, and default grants that let both roles query new tables. One table, orders, with three rows: two belong to Ann, one to Bob. Then we counted what each caller can read.

Rows of the orders table each caller can read (3 rows, 2 of them Ann's). Our test on PostgreSQL 17.11, 1 October 2026.
SetupPublic key, no loginAnn, signed inTable owner
RLS off333
RLS on, no policy003
RLS on, policy using (true)333
RLS on, policy for own rows023
Same policy, read through a view333
Same view, with security_invoker023

Two of those setups do what the app's owner expects. One is safe but breaks the app. Three hand every row to anyone holding the public key.

1. RLS was never switched on

A table created in the Supabase dashboard gets RLS by default. A table created in the SQL editor, or by a migration, does notSource 4. A migration is how an AI coding agent creates tables: it writes create table orders (...) and moves on to the next feature. Nothing in that file is wrong, and nothing in it locks the table.

The fix is one line per table, in the same migration: alter table orders enable row level security;. Supabase can also switch RLS on for every new table with an event trigger, which the dashboard sets up in one clickSource 5.

2. A policy that lets everyone in

Switch RLS on without a policy and Postgres denies every rowSource 6. The table is safe, and every list in the app is suddenly empty. The quickest change that makes the page work again, whether a person or an AI makes it, is a policy with using (true). In our test that policy gave the public key all three rows, exactly as if RLS were off, while the dashboard showed the table as protected. Palmer's advisory names this policy as the one to avoid on sensitive tablesSource 4.

The policy you want compares a column with the caller: using (auth.uid() = user_id), granted to authenticated. Ann then reads her two rows, and the public key reads none.

3. Testing as the owner

Postgres does not apply RLS to the table's ownerSource 6. On Supabase the owner is the postgres role, and the SQL editor runs as postgres unless you pick another role, so every query there returns all three rows whatever the policies say. The last column of our table is the view from the editor: 3 in every row. It cannot tell you what a stranger sees.

Test the way a stranger would: with the public key and no login, or in SQL with set role anon; before the query.

4. A view that reads as its owner

Suppose the policy is right. Then someone asks for a dashboard tile with order totals, and a view appears: create view order_totals as select email, total from orders;. Supabase's documentation is plain about it: views bypass RLS by default, because they are created by the postgres userSource 7. In our test the view gave the public key all three rows, emails included, with the own-rows policy in place.

Since Postgres 15 a view can run with the caller's rights instead: alter view order_totals set (security_invoker = true);. The view then returns what the table would.

§3

Check your own app in fifteen minutes

Do this on your own project only. Nothing below changes data.

  1. Open the app in a private window with the browser's developer tools on the Network tab. Requests to supabase.co/rest/v1/ carry the project URL and the public key. That part is expected.
  2. Ask for a private table as a stranger: request /rest/v1/orders?select=* with only the apikey header and no login. You should get an empty list or an error. Rows mean the table leaks.
  3. In the SQL editor, list the tables with RLS off: select c.relname from pg_class c join pg_namespace n on n.oid = c.relnamespace where n.nspname = 'public' and c.relkind = 'r' and not c.relrowsecurity;
  4. List the policies that let everyone in: select tablename, policyname, cmd from pg_policies where schemaname = 'public' and qual = 'true';. It only catches the literal form; read the rest of your policies too.
  5. List the views that read as their owner: select c.relname from pg_class c join pg_namespace n on n.oid = c.relnamespace where n.nspname = 'public' and c.relkind = 'v' and not coalesce(c.reloptions && array['security_invoker=true', 'security_invoker=on'], false);
  6. Open Security Advisor in the dashboard. It flags tables without RLS and policies that could be stricter.

We ran the three queries against our test database before writing them here. One limit they won't show: RLS hides whole rows, not columnsSource 4. If a profiles row holds a display name everyone may see and a phone number nobody should, a policy can show both or neither. Move the phone number to its own table with its own policy.

Try it yourself For Claude Code
Review this repository for database access rules. Read only: do not edit files, run migrations or connect to any database.

1. Find every SQL migration and schema file (for example supabase/migrations/*.sql). List each table created in the public schema and say whether a migration enables row level security on it.
2. List every policy. Flag any policy whose USING or WITH CHECK is true, and any policy on a table with personal data (emails, phone numbers, addresses, payments) that does not compare a column with auth.uid().
3. List every view over those tables and flag the ones not created with (security_invoker = true).
4. Search the frontend code for a Supabase secret or service_role key.

Report a table with: object, problem, file and line, suggested fix. Do not apply any fix.

A prompt is not an audit: AI review can miss problems, flag ones that aren't there, and answer differently each run. Don't point agents at code you don't trust; text hidden in it can steer them.

  1. RLS on every table the API exposes Including the tables your migrations create.
  2. No using (true) on a table with anyone's data
  3. Views over protected tables use security_invoker
  4. Test as a stranger, not as the owner The public key with no login, or set role anon in SQL.
  5. The secret key stays on a server It bypasses RLS.
  6. Sensitive columns get their own table RLS hides whole rows, not columns.
§4

Or keep the database off the internet

The other design puts your own server between the browser and the database. The browser never holds a database key; the server keeps its own credentials and scopes every query to the user in the session.

Illustrative

Elixir

# The browser asks your server; the server scopes the query to the session's user.
def list_orders(%User{id: user_id}) do
  Order
  |> where([o], o.user_id == ^user_id)
  |> Repo.all()
end

Node

// The browser asks the database with the public key. The policy is the only filter.
const { data: orders } = await supabase.from('orders').select('*')
Fig. 1 — The same read in both designs. On the server the filter is your code; in the browser it is the policy. It shows the idea; it isn't production code.

That moves the risk; it doesn't remove it. A server query that forgets its where leaks just as well. What RLS gives you that server code can't is one rule in the database that covers every path to the table, including the paths nobody has written yet: the next view, the next script, the next feature an agent adds.

So it isn't one or the other. If the table holds other people's data, keep RLS on even behind your own server, and test it as a stranger. Calling the database straight from the browser is fine for a prototype, for data that is public anyway, and for a team that writes and tests its policies. It is a poor fit for an app that holds other people's data while nobody on the team reads the policies.

Sources

  1. Hacking Moltbook: AI Social Network Reveals 1.5M API Keys (Wiz). Retrieved . ↩
  2. CVE-2025-48757 Detail (NVD). Retrieved . ↩ ↩
  3. API keys (Supabase Docs). Retrieved . ↩
  4. CVE-2025-48757 (Matt Palmer). Retrieved . ↩ ↩ ↩ ↩
  5. Supabase Security Retro: 2025. Retrieved . ↩
  6. PostgreSQL 17 Documentation, 5.9 Row Security Policies. Retrieved . ↩ ↩
  7. Row Level Security (Supabase Docs). Retrieved . ↩

Published · Updated Report an error (support@agentum.cloud)