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. - Find table names two ways and merge them: request the PostgREST schema
endpoint (
/rest/v1/) with the anon key, and read every.from("table")call already sitting in the same bundle the anon key came from. Supabase now rejects the schema endpoint for anon keys on new projects, so the bundle’s own table names are what keeps this check working rather than silently degrading to a “Supabase detected” note. - Issue a real unauthenticated read against each table found, up to 12.
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 |
| Rows returned, but the table looks like public catalogue content | Info | Products, posts and the like, with nothing personal in the sampled rows. Confirm it is meant to be public |
| 200, but empty | Info | Inconclusive: see below |
Unauthenticated INSERT created a row | Critical | Anyone can write |
INSERT rejected with a validation error | Info | Inconclusive: no row was created |
| Storage bucket list exposed | Medium | Object names enumerable |
| Requests refused | Info | RLS appears configured |
The empty-table case is reported as an Info note and not as a finding of weight.
PostgREST answers 200 [] both for a table whose row-level security hides every
row from the caller and for a table with no policy that happens to be empty, so
the response cannot tell them apart. An empty table with no policy is still not
secure. The note is there so you check, without SecScan claiming a hole it has not
seen.
The write probe is safe
The INSERT test sends {"__vibescan_probe__": true} — a column that cannot
exist in any real schema. A 400, 409 or 422 is not proof that the write
would have been refused: PostgREST checks the body against the schema before row
level security is evaluated, so the answer is the same either way. That case is
reported as Info, “write access not confirmed”, and the fix is to check your
INSERT policies. The Critical finding needs a 2xx answer, meaning a row was
actually accepted, and then 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-30