On May 29, 2025, Matt Palmer disclosed CVE-2025-48757: Lovable-generated apps could expose sensitive data through insufficient Supabase Row Level Security on client-controlled database requests. Vibe App Scanner reports 170+ affected apps and 303 vulnerable endpoints across 1,645 scanned Lovable apps. The exploit was not exotic: inspect the browser request, reuse the publishable or legacy anon key, and ask the REST endpoint for rows it should never return.
If your app ships a Supabase key in the browser, your database is one curl away from exposing data unless every table reachable through the Supabase Data API/PostgREST layer has grants and policies you can defend out loud. These five queries take about ten minutes to run in the Supabase SQL editor as postgres. They read catalog tables only; they do not modify data.
Supabase has started a 2026 rollout toward explicit Data API grants for new public tables. That helps future projects, but it does not repair existing grants or prove your policies match your tenants.
Imagine you're three weeks past a Lovable launch. You have 800 sign-ups, a waitlist for the next pricing tier, and a browser bundle with an apikey header sitting in every network request. You copy that key, run https://yourproject.supabase.co/rest/v1/users?select=*, and the question is simple: does it answer?
If you are not the person who owns SQL, send this to whoever can open the Supabase SQL editor and ask for screenshots of every non-empty result.
The first query catches the Lovable failure mode
This is the direct CVE check. If a table sits in a schema exposed through the Supabase Data API/PostgREST layer, grants SELECT to anon or authenticated, and has RLS disabled, browser clients can read every row those grants allow. Default projects usually expose public; if your API settings expose api, app, or another schema, add it to the CTE.
with exposed_schemas(schema_name) as (
values
('public') -- add one row per schema listed in Supabase Data API settings
)
select
n.nspname as schema,
c.relname as table,
c.relrowsecurity as rls_enabled,
pg_catalog.has_table_privilege('anon', c.oid, 'SELECT') as anon_can_select,
pg_catalog.has_table_privilege('authenticated', c.oid, 'SELECT') as auth_can_select
from pg_catalog.pg_class c
join pg_catalog.pg_namespace n on c.relnamespace = n.oid
join exposed_schemas es on es.schema_name = n.nspname::text
where c.relkind in ('r', 'p')
and not c.relrowsecurity
and (
pg_catalog.has_table_privilege('anon', c.oid, 'SELECT')
or pg_catalog.has_table_privilege('authenticated', c.oid, 'SELECT')
);
Bad looks like any row. Good looks like an empty result. The fix starts with alter table public.<your_table> enable row level security;, then real policies, because RLS without policies blocks browser/Data API access for anon and authenticated until policies exist.
RLS without policies is still a broken app
The panic fix after a security scare is often "enable RLS everywhere." That can create the opposite problem: tables that your product needs are now default-deny. With grants in place, SELECT returns no rows and writes are rejected. If you see permission denied for table, check GRANT first; Postgres denied table access before RLS had a chance to run.
with exposed_schemas(schema_name) as (
values
('public') -- add one row per schema listed in Supabase Data API settings
)
select
n.nspname as schema,
c.relname as table,
c.relrowsecurity as rls_enabled,
pg_catalog.has_table_privilege('anon', c.oid, 'SELECT') as anon_can_select,
pg_catalog.has_table_privilege('authenticated', c.oid, 'SELECT') as auth_can_select,
pg_catalog.has_table_privilege('authenticated', c.oid, 'INSERT') as auth_can_insert,
pg_catalog.has_table_privilege('authenticated', c.oid, 'UPDATE') as auth_can_update,
pg_catalog.has_table_privilege('authenticated', c.oid, 'DELETE') as auth_can_delete
from pg_catalog.pg_class c
join pg_catalog.pg_namespace n on c.relnamespace = n.oid
join exposed_schemas es on es.schema_name = n.nspname::text
left join pg_catalog.pg_policy p on p.polrelid = c.oid
where c.relkind in ('r', 'p')
and c.relrowsecurity
group by n.nspname, c.relname, c.relrowsecurity, c.oid
having count(p.polname) = 0;
Bad looks like an active application table in this list, especially one with client-role grants. Good is empty, unless the table genuinely should not be reachable through the Data API. The minimum useful repair is a policy that names the role and filters by the current user, for example to authenticated using ((select auth.uid()) = user_id). If the table should not be reachable, revoke the client grants or move it to a private schema.
USING (true) makes private tables public
This is the classic AI-generated RLS bug. On private data, USING (true) lets every matching role read every row, and auth.role() = 'authenticated' only proves the caller signed in. It does not prove they own the row, belong to the tenant, or should see another customer's data.
with exposed_schemas(schema_name) as (
values
('public') -- add one row per schema listed in Supabase Data API settings
)
select
schemaname as schema,
tablename as table,
policyname as policy,
cmd as command,
roles,
qual as using_expression,
with_check
from pg_policies
where schemaname::text in (select schema_name from exposed_schemas)
and (
(
cmd in ('SELECT', 'UPDATE', 'DELETE', 'ALL')
and (
coalesce(qual, '') ~* '^\(*[[:space:]]*true[[:space:]]*\)*$'
or coalesce(qual, '') ~* 'auth\.role\(\)[[:space:]]*=[[:space:]]*''authenticated'''
)
)
or (
cmd in ('INSERT', 'UPDATE', 'ALL')
and (
coalesce(with_check, '') ~* '^\(*[[:space:]]*true[[:space:]]*\)*$'
or coalesce(with_check, '') ~* 'auth\.role\(\)[[:space:]]*=[[:space:]]*''authenticated'''
)
)
or (
cmd = 'INSERT'
and with_check is null
)
);
Bad looks like using_expression = true, with_check = true, a role-only check, or a policy you cannot explain in one sentence. A null using_expression on an INSERT policy is normal; WITH CHECK is the gate for new rows, so this query audits it separately. Good policies reference auth.uid(), a tenant column, a membership table, or a security-definer helper that makes an ownership decision. At appssemble, when we build this in engineering work, every tenant-scoped table gets the same test: why should this caller see or write this row?
TO public makes anon part of the policy surface
In PostgreSQL, a policy with no TO clause applies to public. In Supabase terms, that includes anon, so a policy that looked like "logged-in users can read" may still run for anonymous browsers if the table grants and policy expression allow it.
with exposed_schemas(schema_name) as (
values
('public') -- add one row per schema listed in Supabase Data API settings
)
select
schemaname as schema,
tablename as table,
policyname as policy,
cmd as command,
roles,
qual as using_expression
from pg_policies
where schemaname::text in (select schema_name from exposed_schemas)
and (
roles = '{public}'
or 'anon' = any(roles)
)
and cmd in ('SELECT', 'ALL');
Bad looks like {public} or anon on a user-data table. This is not automatically a breach; a public marketing table may intentionally allow anonymous reads, and missing grants still block access. It is a review list for private tables, not a verdict by itself. Good looks like to authenticated on every private table, with a small, explicit whitelist for content that is genuinely public. If you meant "any signed-in user," write that; if you meant "the owner," write the owner check too.
raw_user_meta_data turns client updates into admin rights
Supabase users can update their own user metadata from the client. That makes raw_user_meta_data and user_metadata the wrong place to store authorization flags like role = admin.
with exposed_schemas(schema_name) as (
values
('public') -- add one row per schema listed in Supabase Data API settings
)
select
schemaname as schema,
tablename as table,
policyname as policy,
cmd as command,
qual as using_expression,
with_check
from pg_policies
where schemaname::text in (select schema_name from exposed_schemas)
and (
coalesce(qual, '') ilike '%raw_user_meta_data%'
or coalesce(qual, '') ilike '%user_metadata%'
or coalesce(with_check, '') ilike '%raw_user_meta_data%'
or coalesce(with_check, '') ilike '%user_metadata%'
);
Bad looks like any row. Good is empty, with authorization stored in server-managed app_metadata, or in a dedicated roles table checked through a security-definer function. JWT claims can be stale until refresh, so use the roles-table path when fast revocation matters. The difference is not style; it is whether a signed-in user can promote themselves with one client call.
The Security Advisor is a start, not a verdict
Supabase's Advisor and Splinter checks are worth running. They cover disabled RLS, RLS enabled with no policy, policies on RLS-disabled tables, user-metadata references, permissive policy patterns, sensitive exposed columns, materialized or foreign tables in the API, public bucket listing, and the auth_rls_initplan performance warning. They are still not a full RLS audit. They will not read your product model and decide whether USING (true) is fine for blog_posts and catastrophic for user_profiles.
That is why we still dump pg_policies and read the policies line by line. These five queries check table policies; they do not audit views, RPC functions, Storage buckets, GraphQL exposure, service-role leaks, or semantic tenant bugs. On large tables, we also check the PostgreSQL RLS auth.uid() performance pattern: wrapping auth.uid() as (select auth.uid()) and indexing columns like user_id or account_id has been shown in Supabase benchmarks to produce 100x+ speedups. A correct policy that times out at 100,000 rows is still not production-ready.
This is also why internal ownership matters. Grovs processes 10M+ events daily, and that kind of load does not forgive vague access rules, missing indexes, or wishful thinking about defaults. The same discipline applies to a 100-user Supabase app before it becomes a 100,000-user problem.
Run Query 1 before the next feature
The lesson of CVE-2025-48757 is not that Supabase is unusable. The lesson is that vibe-coded apps often stop at "RLS is on" when the real question is "which rows can this role read, write, and mutate?"
Run the five queries. If any of them return rows on tables that hold user data, do not ship the next feature first. Fix the policies, test them as different users, and if you want a second pair of eyes on the schema, book a call.