Row Level Security mistakes in vibe-coded Supabase apps, and how to test for each.
In a Supabase app the browser queries the database with a public key, so Row Level Security is the only thing between that key and your tables. The mistakes that leak data are few: tables with RLS off, policies that say true, rules that trust user_metadata, the service_role key in the bundle, and storage without policies.
Facts checked
Why one missing policy exposes a whole table
Apps built with Lovable, Bolt and similar tools usually call Supabase straight from the browser with supabase-js. The key they use is meant to be public. Supabase’s own docs say of it: "Anyone can read it, so it only reaches what Row Level Security allows." Anyone can copy the key out of your JavaScript and send their own requests to your project’s REST endpoint. If a table has no RLS, or a policy that lets every row through, that request returns the table.
This has happened at scale. The record for CVE-2025-48757 says "an insufficient database Row-Level Security policy in Lovable through 2025-04-15 allows remote unauthenticated attackers to read or write to arbitrary database tables of generated sites." Lovable disputes it, on the grounds that each customer is responsible for protecting their app’s data. The researchers’ write-up lists what was reachable: user tables with names and emails, third-party API keys, payment and subscription records. In February 2026 The Register reported a showcased Lovable app exposing 18,697 user records; the researcher put it down to missing RLS and inverted access checks. Lovable said every project gets a free pre-publish security scan, that this one’s recommendations had not been applied, and that the vulnerable database was not hosted by Lovable.
Responsibility sits with whoever ships the app. The checks below take an afternoon.
The six mistakes
Each one matches a check in Supabase’s Security Advisor (Database → Advisors), which names it with the lint shown.
| Mistake | What someone with the public key can do | Advisor lint | Fix |
|---|---|---|---|
RLS off on a table in public † † † | Read every row with ?select=*, and insert, update or delete where anon has the grant | 0013_rls_disabled_in_public | enable row level security, then write policies. Tables made in the SQL editor or a migration don’t get RLS on by default; Dashboard tables do. |
A policy that says using (true) † † | The same as no RLS for that operation. Permissive policies combine with OR, so one true cancels the stricter ones. | 0024_permissive_rls_policy | Scope it: (select auth.uid()) = user_id. Keep true only for data meant to be public. |
Policies that trust user_metadata † † † | Call supabase.auth.updateUser({ data: { role: 'admin' } }) and pass any check that reads it | 0015_rls_references_user_metadata | Put roles in app_metadata, which users can’t edit, or in a table only the server writes. |
The service_role or secret key in the browser † | Everything. That role has BYPASSRLS; no policy applies. | None: search the bundle | Keep it in server code only. Rotate it if it ever shipped. |
| Storage buckets with broad policies † † | List and download every file, or upload into other users’ folders | 0025_public_bucket_allows_listing | Per-user folder policies on storage.objects with storage.foldername(name). |
Views and SECURITY DEFINER functions † † | Read through a view that runs as its creator, or call a function that runs as its owner | 0010_security_definer_view, 0028_anon_security_definer_function_executable | security_invoker = true on views; revoke execute from anon on owner-rights functions. |
† Competitor details come from each vendor’s own published pages on the dates shown; prices and plans change, so check the vendor before you decide. RLS off on a table in `public` (2026-10-29) · RLS off on a table in `public` (2026-10-29) · RLS off on a table in `public` (2026-10-29) · A policy that says `using (true)` (2026-10-29) · A policy that says `using (true)` (2026-10-29) · Policies that trust `user_metadata` (2026-10-29) · Policies that trust `user_metadata` (2026-10-29) · Policies that trust `user_metadata` (2026-10-29) · The `service_role` or secret key in the browser (2026-10-29) · Storage buckets with broad policies (2026-10-29) · Storage buckets with broad policies (2026-10-29) · Views and `SECURITY DEFINER` functions (2026-10-29) · Views and `SECURITY DEFINER` functions (2026-10-29)
Step 1: read the state from SQL
In the Supabase SQL editor, run these three queries. The first lists tables with RLS off. The second prints every policy with its condition, so a true or a user_metadata stands out. The third shows what the anon role can touch at all. Supabase notes that RLS and grants both apply: a table needs a grant to be reachable, and RLS to limit which rows.
-- 1. Which tables have RLS on (false = readable by the public key)
select relname, relrowsecurity
from pg_class
where relnamespace = 'public'::regnamespace and relkind = 'r'
order by relrowsecurity, relname;
-- 2. Every policy, with its condition. Look for "true" and user_metadata.
select tablename, policyname, cmd, roles, qual, with_check
from pg_policies
where schemaname in ('public', 'storage')
order by tablename;
-- 3. What the anon role is granted
select table_name, string_agg(privilege_type, ', ') as privileges
from information_schema.role_table_grants
where grantee = 'anon' and table_schema = 'public'
group by table_name;Step 2: test from outside with the anon key
Reading policies tells you what you meant. Calling the API tells you what happens. Use the same public key the app ships, from the outside, as an attacker would:
- Every table, logged out. Anything private should return
[]. A table with RLS on and no policy returns nothing, which is the safe default. - Two accounts. Signed in as B, ask for A’s rows by id. Then try
PATCHandDELETEon one of A’s rows. Run the write tests on a copy of the project, not on production data. - Storage, logged out. In the browser console of the live app, call
supabase.storage.from('<bucket>').list()for each bucket. A list of other people’s files is a leak. - The bundle. Search the built JavaScript for
service_roleand for your secret key itself; the production checklist has the command.
URL=https://<project-ref>.supabase.co
KEY=<publishable or anon key, copied from the app's bundle>
# Logged out: should return [] or an error for anything private
curl "$URL/rest/v1/profiles?select=*&limit=5" -H "apikey: $KEY"
# Signed in as user B (access token from the browser's session):
# should return only B's rows, never A's
curl "$URL/rest/v1/orders?select=*&user_id=eq.<A's id>" \
-H "apikey: $KEY" -H "Authorization: Bearer <B's access token>"Step 3: fix it
Wrapping auth.uid() in (select …) is Supabase’s own recommendation: Postgres evaluates it once per statement instead of once per row. Add to authenticated so the policy is never even considered for logged-out requests.
-- Turn RLS on. With no policy, nothing is returned to the public key.
alter table public.orders enable row level security;
-- Owners read and write their own rows
create policy "own orders" on public.orders
for all to authenticated
using ((select auth.uid()) = user_id)
with check ((select auth.uid()) = user_id);
-- Admin checks read app_metadata, which users cannot edit
create policy "admins read all" on public.orders
for select to authenticated
using ((auth.jwt() -> 'app_metadata' ->> 'role') = 'admin');
-- Views run with the caller's rights (Postgres 15+)
alter view public.order_summary set (security_invoker = true);Storage policies
Supabase Storage keeps its file records in storage.objects, and the same RLS rules apply. Uploads are blocked until you add an INSERT policy; upserts also need SELECT and UPDATE. A public bucket serves any file to anyone with its URL, so keep private files out of public buckets whatever the policies say.
-- Uploads only into a folder named after the user's id
create policy "own folder upload" on storage.objects
for insert to authenticated
with check (
bucket_id = 'documents'
and (storage.foldername(name))[1] = (select auth.uid()::text)
);Plain Postgres, Canner included: same RLS, different exposure
RLS is a Postgres feature, not a Supabase one, and it works the same on any Postgres, including a Canner database. What Canner doesn’t have is Supabase’s REST API in front of the database, the anon and authenticated roles, or auth.uid(). A Canner database is reachable from inside Canner (your app, Data Workshop, scheduled queries) and, if you turn on external access, from the IP addresses you allow. There is no public key to copy.
So most apps on a normal backend enforce access in server code. The equivalent of a missing policy is a route that loads orders where id = $1 without and user_id = $2. Test it the same way: two accounts, swap the id.
RLS can still be a second layer behind those checks. Set the user for each transaction and write policies against it:
alter table notes enable row level security;
alter table notes force row level security; -- the table owner too
create policy notes_owner on notes
using (user_id = nullif(current_setting('app.user_id', true), '')::uuid);Setting the user from your app
- `set_config(…, true)` lasts for the transaction only, so the user can’t leak into the next request on a pooled connection.
- `nullif(…, '')`: with no user set, the policy compares against
NULLand returns no rows. On a reused connection the setting can come back as an empty string, which would fail theuuidcast;nullifturns it intoNULL. - The table owner skips RLS unless the table has
FORCE ROW LEVEL SECURITY, and superusers andBYPASSRLSroles always skip it. If your app connects with the login that ran the migrations, it owns the tables. Either force RLS or give the app a role that doesn’t own them; on the dedicated instance,canner db least-privilegedoes that in one step. - Forced RLS and Canner backups: a backup taken as the owner stops with a row-level-security error on a forced table. Set Backups run as to the instance superuser on the dedicated instance (docs).
// node-postgres: set the user for this transaction only
await client.query("BEGIN");
await client.query("SELECT set_config('app.user_id', $1, true)", [session.userId]);
const { rows } = await client.query("SELECT * FROM notes WHERE id = $1", [id]);
await client.query("COMMIT");Questions
Is the Supabase anon or publishable key a secret?
No. It is meant to ship in the browser, and Supabase says it reaches only what Row Level Security allows. The secret or service_role key is the one that must never leave the server.
How do I check whether RLS is enabled on all my Supabase tables?
Run select relname, relrowsecurity from pg_class where relnamespace = 'public'::regnamespace and relkind = 'r' in the SQL editor, or open Database → Advisors, which flags tables in public with RLS off as 0013_rls_disabled_in_public.
What is CVE-2025-48757?
A 2025 CVE describing insufficient Row Level Security in Lovable-generated sites that let unauthenticated users read or write their database tables. Lovable disputes it, saying each customer is responsible for their app’s data. The fix on your side is the same either way: RLS on, scoped policies, tested with the anon key.
Is it safe to use user_metadata in an RLS policy?
No. Users can change their own user_metadata with updateUser, so a policy that checks it for a role can be passed by anyone. Supabase recommends app_metadata, which users cannot modify.
Do I need RLS if my app has its own backend?
Not to stay safe, as long as every route checks the signed-in user on the server. RLS is a useful second layer: set the user per transaction with set_config and force RLS so the table owner is covered too.
Host the app, keep the data in Montréal.
Deploy an AI-built app with a Postgres database that has no public API in front of it. More in host AI-built apps.