This is the single most common Critical finding on applications built quickly on a hosted backend, and the one most often misunderstood.
The anon key is not the vulnerability
Your Supabase anon key is in your JavaScript bundle. It is supposed to be. It identifies the project and nothing more — it carries no privileges of its own. SecScan does not flag it, and any tool that reports “API key exposed in client-side code” for an anon key is wasting your time.
The vulnerability is what that key can reach. Supabase enforces access through Row Level Security (RLS) policies on each table. A table with RLS disabled answers the anon key completely: every row, to anyone who reads your bundle. This is catalogued as CVE-2025-48757.
The distinction matters for a different reason too. A service_role key in
client-side JavaScript is Critical and unrelated — it bypasses RLS entirely,
so no policy anywhere will save you. SecScan reports that separately.
How SecScan tests it
Detection is behavioural, not pattern-matching:
- Extract
supabaseUrland the anon key from the JavaScript bundle. - Request the PostgREST OpenAPI document to enumerate up to 12 tables.
- Issue a real unauthenticated read against each one.
The read tests run on any scan. The write test is an active probe, so it only runs once you have verified the domain.
The result is graded on what actually came back:
| Result | Severity | Meaning |
|---|---|---|
| Rows returned | Critical | Live data is public |
| 200, but empty | High | No policy — the table is just empty today |
Unauthenticated INSERT accepted | Critical | Anyone can write |
| Storage bucket list exposed | Medium | Object names enumerable |
| Requests refused | Info | RLS appears configured |
The empty-table case is deliberately still a finding. An empty table with no policy is not secure; it is pre-launch.
The write probe is safe
The INSERT test sends {"__vibescan_probe__": true} — a column that cannot
exist in any real schema. A 400 or 422 proves the write was authorised and
then rejected on schema grounds, which answers the question without creating a
row. In the rare case a permissive schema returns 201, the created row is
deleted immediately by its returned ID. No PATCH or DELETE is ever sent
against rows that already exist.
Fixing it
Enable RLS on every table, then write policies. Enabling RLS without policies denies everything, which is the correct place to start:
alter table public.profiles enable row level security;
create policy "read own profile"
on public.profiles for select
using (auth.uid() = user_id);
create policy "update own profile"
on public.profiles for update
using (auth.uid() = user_id)
with check (auth.uid() = user_id);
Two things to get right:
usingvswith check.usingfilters which existing rows the statement can see;with checkvalidates rows being written. Anupdatepolicy needs both, or a user can move a row to another owner.- Audit every table, not the obvious ones. Join tables, audit logs and
_backupcopies are where the data actually leaks, because nobody thinks of them as user-facing.
Find the gaps with:
select tablename
from pg_tables
where schemaname = 'public'
and tablename not in (
select tablename from pg_tables t
join pg_class c on c.relname = t.tablename
where c.relrowsecurity
);
Rotate anything that was reachable. If a table held API keys, invite tokens or password reset tokens while RLS was off, assume they are public and rotate them. Turning RLS on does not un-read the rows.
Last reviewed 2026-09-07