← All guides
Critical Reported as: Tables returning rows to unauthenticated anon-key requests

Supabase tables readable with the anon key

CVE-2025-48757: how a Supabase project ships with Row Level Security off, why the anon key is not the problem, and how to confirm and fix it.

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:

  1. Extract supabaseUrl and the anon key from the JavaScript bundle.
  2. Request the PostgREST OpenAPI document to enumerate up to 12 tables.
  3. 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:

ResultSeverityMeaning
Rows returnedCriticalLive data is public
200, but emptyHighNo policy — the table is just empty today
Unauthenticated INSERT acceptedCriticalAnyone can write
Storage bucket list exposedMediumObject names enumerable
Requests refusedInfoRLS 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:

  • using vs with check. using filters which existing rows the statement can see; with check validates rows being written. An update policy needs both, or a user can move a row to another owner.
  • Audit every table, not the obvious ones. Join tables, audit logs and _backup copies 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