Picture an invoicing app called Billtrack, built in a weekend with Lovable. It has a Supabase database with one table, invoices, and two users, Alice and Bob. Alice should see her two invoices. Bob should see his one.
The app works. Alice logs in and sees her invoices. Then anyone who opens the browser's network tab can copy the Supabase URL and the anon key out of a request, run one curl, and get all three invoices, including Bob's.
That is the bug behind CVE-2025-48757. A security researcher scanned 1,645 apps from Lovable's public showcase and found 170 with Supabase tables readable using only the public key. The exposed data included emails, addresses and in some cases API keys. The NVD entry notes that Lovable disputes the CVE, on the grounds that each customer is responsible for protecting their own app's data. Either way, the fix is in the app's database.
It wasn't a one-off. In October 2025, the security firm Escape.tech scanned more than 5,600 public apps built with Lovable, Base44, Create.xyz, Vibe Studio and Bolt.new. It found more than 2,000 vulnerabilities, more than 400 exposed secrets and 175 cases of exposed personal data, including medical records and bank account numbers. It named missing or misconfigured RLS and Supabase service role keys sitting in frontend bundles among the main causes.
We wanted to know whether that is still true a year later, so we looked at the code ourselves.
What we found in 38 public Lovable codebases
In October 2026 we searched GitHub for public repositories generated by Lovable, using the lovable-tagger package it adds to every project. We found 933. Of those, 218 include their Supabase migrations, the SQL files that create the database tables and their security rules. We picked 40 at random, and 38 of them defined tables in the public schema. We replayed each repository's migrations in order and recorded the final state of every table, then checked the serious findings by hand.
We only read code. We didn't connect to any live app or database, and we don't name any repository here.
| Finding | Repositories | Share |
|---|---|---|
| At least one table with RLS turned off | 2 of 38 | 5% |
| Policies that let anyone, signed in or not, update or delete every row | 6 of 38 | 16% |
| Policies that let any signed-in user update or delete every row | 4 of 38 | 11% |
| At least one of the three above | 10 of 38 | 26% |
A view that doesn't apply the table's RLS (security_invoker not set) |
6 of 38 | 16% |
The first row surprised us. Only 2 of the 826 tables we found had RLS off. Missing RLS, the problem behind CVE-2025-48757, has become rare in Lovable's generated code. The risk has moved into the policies themselves, which a check for "is RLS on" does not catch.
Two patterns stood out.
The first is a temporary policy that never left. One billing app created FOR ALL USING (true) policies on its customers, invoices and payments tables, with a comment saying they allowed all operations "for now" and could be restricted later. Later migrations added proper per-user policies next to them but never dropped the open ones. Because Postgres combines policies with OR, the open policy still wins, so anyone with the public key could read and change every invoice.
The second is a policy named for one role that applies to all of them. Another app had a policy called "Service can manage all jobs" with FOR ALL USING (true) and no TO service_role clause. Without that clause a policy applies to every role, so the background-job table was writable by anonymous visitors.
Some of these repositories are prototypes, and a live database can differ from its migrations if someone changed it by hand. So these numbers describe what the code would create, not what is running. They are still the code a founder would deploy.
The rest of this post shows exactly what each kind of mistake exposes, and ends with the checklist we use.
Why AI-built apps hit this so often
A traditional web app has a backend server between the browser and the database. The server decides who can see what.
Apps generated by Lovable, Bolt and similar tools usually skip that server. The browser talks to Supabase directly, through an auto-generated REST API, and it sends a key that is meant to be public: the anon key. Supabase's own docs describe what makes that safe. Postgres row level security (RLS) runs on every request and filters rows per user. The docs put it plainly: enable RLS on every table in an exposed schema.
So the anon key is not the secret. The RLS policies are the security model. If a table has RLS off, the public key opens all of it. RLS is not switched on for you when a table is created with SQL. Supabase's docs point to event triggers if you want that to happen automatically, and they note that on some projects a new table in public starts with every privilege already granted to all three API roles.
What we tested
We rebuilt Billtrack's database in a lab: PostgreSQL 16, PostgREST 12.2.3 (the API layer Supabase uses), the same three roles Supabase uses (anon, authenticated, service_role), and an auth.uid() function that reads the user ID from the request's JWT, as Supabase does. Then we called the API as an anonymous visitor, as Alice, and as the service role, after each change.
State of the invoices table |
Anonymous visitor | Alice |
|---|---|---|
| RLS off | All 3 invoices | All 3 invoices |
| RLS on, no policies | Nothing | Nothing |
RLS on, policy scoped to auth.uid()
|
Nothing | Her 2 invoices |
Same, plus an extra using (true) policy |
All 3 invoices | All 3 invoices |
| A view over the table, default settings | All 3 invoices | All 3 invoices |
The rest of this post goes through each row.
Check 1: run the anon-key test on your own app
This is the test a stranger would run. Use it only on an app you own.
curl 'https://YOUR-PROJECT.supabase.co/rest/v1/invoices?select=*' \
-H "apikey: YOUR_ANON_KEY" \
-H "Authorization: Bearer YOUR_ANON_KEY"
Both values are already in your frontend code. If this returns rows that belong to real users, the table is open to the internet.
Reads are only half of it. With RLS off, our anonymous test also inserted a new invoice and changed the amount on Bob's invoice from 4,300 to 1. Both requests succeeded. An open table is writable, not just readable.
Check 2: turn RLS on for every table in public
This query lists every table and whether RLS is on:
select tablename, rowsecurity
from pg_tables
where schemaname = 'public';
Any false is a table the anon key can reach. Turning RLS on is one line:
alter table invoices enable row level security;
Expect the app to "break" right after. With RLS on and no policies, Postgres denies everything, so Alice also saw an empty list in our test. That's correct behavior. Access now has to be granted on purpose.
Check 3: write policies that name the user
The policy that fixed Billtrack lets a signed-in user read only their own rows:
create policy "read own invoices" on invoices
for select to authenticated
using ((select auth.uid()) = user_id);
After this, Alice got her two invoices and the anonymous visitor got nothing. Two details matter:
-
to authenticatedkeeps anonymous visitors out of the policy entirely. Without a role, a policy applies to everyone. - Wrapping
auth.uid()in(select ...)is Supabase's own performance advice. It lets Postgres evaluate the function once per query instead of once per row.
Writes need their own policies. With RLS on and only a read policy, Alice's insert was rejected with new row violates row-level security policy. The insert policy uses with check, which tests the new row:
create policy "insert own invoices" on invoices
for insert to authenticated
with check ((select auth.uid()) = user_id);
Check 4: look for policies that say true
When an AI tool hits a permission error, a common "fix" is a policy that allows everything. We added one next to the correct policy:
create policy "anyone" on invoices for select using (true);
The anonymous visitor got all three invoices again. Postgres policies are permissive by default, so a row is visible if any policy allows it. One loose policy cancels every careful one.
Writes have the same trap. With an insert policy of with check (true), Alice created an invoice with Bob's user_id, and it was accepted. With the auth.uid() version, the same request was rejected.
This query shows every policy and its condition, so true stands out:
select tablename, policyname, cmd, roles, qual, with_check
from pg_policies
where schemaname = 'public';
Check 5: check every view
Views are easy to miss. We created a simple view over invoices and granted it to the API roles, with RLS on and correct policies on the table. The anonymous visitor read all three invoices through the view.
Supabase's docs explain why: views bypass RLS by default because they are usually created by the postgres user. On Postgres 15 and later, one setting makes a view obey the table's policies:
alter view invoice_totals set (security_invoker = true);
After that, the anonymous visitor got nothing through the view and Alice got her two rows. On older Postgres versions, revoke access to the view from anon and authenticated, or move it to a schema the API doesn't expose.
Check 6: keep the service role key off the browser
Supabase's secret key maps to the service_role Postgres role, which has the bypassrls attribute. In our lab, the service role read all three invoices even with every policy in place. That's by design, for trusted server code.
It becomes a leak when the key ends up in frontend code. Environment variables with a NEXT_PUBLIC_ prefix in Next.js, or VITE_ in Vite, are built into the JavaScript the browser downloads. Search your codebase and your built files for the secret key. If it appears anywhere in client code, rotate it in the Supabase dashboard, then move the code that needs it into a server function.
Moltbook, an AI-agent social network, shows what one exposed key does. In February 2026, researchers at Wiz reported that a Supabase key in Moltbook's client-side JavaScript gave unauthenticated read and write access to its whole production database, because the tables had no RLS policies. Wiz reported 1.5 million API authentication tokens and about 35,000 email addresses in the exposed data. Once it was reported, the fix was reportedly two SQL statements.
What surprised us
- RLS fails silently. When a policy blocked a read or an update, the API returned an empty list with HTTP 200, not an error. An anonymous update to Bob's invoice came back as
[], status 200, and nothing changed. For a founder testing their own app, "it returns nothing" looks like a bug to fix, and the quickest fix is theusing (true)policy from Check 4. - The view leak. We expected the view to follow the table's policies. It didn't, until
security_invokerwas set. - Our own test setup lied to us once. We ran a drop-and-create of two policies as one
psqlcommand. The create failed because the policy already existed, so Postgres rolled back the drop too, and the next test passed for the wrong reason. We only caught it because the inserted rows didn't match what we expected. Re-run security tests from a clean state, and check the data, not just the status code.
The checklist
Before an AI-built Supabase app goes live:
- Run the anon-key
curlagainst every table. Real user data in the response means the table is open. - Run the
pg_tablesquery. Every table inpublicshould showrowsecurity = true. - Give every table a policy per operation (select, insert, update, delete) that names a role and checks
auth.uid(). - Run the
pg_policiesquery and remove any policy whose condition istrue, unless the data is meant to be public. - Set
security_invoker = trueon every view, or revoke API access to it. - Search client code and build output for the secret key. Rotate it if found.
If you'd rather have someone run these checks on your app, talk to us. We do this as a fixed-scope audit for apps built with Lovable, Bolt and similar tools.
FAQ
Is the Supabase anon key a secret?
No. It's designed to be public and is shipped in every frontend that uses Supabase from the browser. Security comes from row level security policies, not from hiding the key.
What is CVE-2025-48757?
A vulnerability record for Lovable-generated apps whose Supabase tables could be read or written with only the public anon key, because row level security was missing or incomplete. The researcher found 170 affected apps out of 1,645 scanned. Lovable disputes the CVE, saying each customer is responsible for their own app's data.
Why does my app show no data after I enable RLS?
With RLS on and no policies, Postgres denies every row. Add a policy for each operation the app needs, scoped to auth.uid(). Don't fix it with using (true), which opens the table again.
Do Supabase views respect row level security?
Not by default, because views are usually created by the postgres user and run with its rights. On Postgres 15 and later, set security_invoker = true on the view.
How common are RLS mistakes in Lovable apps?
In our October 2026 review of 38 randomly chosen public Lovable codebases, only 2 of 826 tables had RLS turned off, but 10 of the 38 repositories (26%) had policies letting anyone, or any signed-in user, update or delete every row in at least one table.
Is it safe to use the service role key in the browser?
No. The service role bypasses row level security completely. Keep it in server-side code only.
Originally published on inople.com.
Top comments (1)
Strong write-up. The "temporary policy that never left" is the one I would turn into a CI check, because it regresses silently: a later migration adds a correct policy next to the open one and nothing fails. A test that seeds two users and asserts the anon key sees zero rows per table, plus a query over pg_policies that fails on a true condition or a missing TO clause, catches it the day it is added.