Cenk Kurtoğlu©26

Supabase security · 6 minute read

The first 15 minutes of a Supabase security audit

A fixed-price audit has a budget, so the order of the work matters more than the depth of any single step. This is the sequence I run on every engagement, in the order that finds the highest-probability leak per minute. Steps 1–3 find the critical findings in the majority of projects I audit; steps 4–6 find the ones that survive a casual review.

Everything below is read-only reconnaissance — unless you explicitly commission write-path testing, the audit never mutates your data.

Step 1 — the anon-key probe (2 minutes)

Before any SQL, stand where the attacker stands: the public anon key, over REST. Every table, one request each, select=*&limit=1:

for t in $(curl -s "$SUPABASE_URL/rest/v1/" \
  -H "apikey: $ANON_KEY" | jq -r 'keys[]'); do
  code=$(curl -s -o /tmp/probe.json -w '%{http_code}' \
    "$SUPABASE_URL/rest/v1/$t?select=*&limit=1" -H "apikey: $ANON_KEY")
  rows=$(jq 'length' /tmp/probe.json 2>/dev/null || echo 0)
  echo "$t -> HTTP $code, rows: $rows"
done

Reading the results: 200 with rows on a user-scoped table is a critical finding — stop and document it. HTTP 200 with [] is healthy. 42501 means RLS denied the read (good). An empty object {} often means an exposed view with no matching rows — note the view for step 3.

Step 2 — the policy census (3 minutes)

What exists versus what restricts are different questions, and the census answers both per table:

select c.relname as table_name, c.relrowsecurity as rls_enabled,
       count(p.polname) as policies,
       bool_or(p.polqual::text = 'true') as permissive_exists
from pg_class c
join pg_namespace n on n.oid = c.relnamespace
left join pg_policy p on p.polrelid = c.oid
where n.nspname = 'public' and c.relkind = 'r'
group by c.relname, c.relrowsecurity
order by rls_enabled, policies;

Three rows to flag immediately: rls_enabled = false (no RLS — the table falls back to grants), policies = 0with RLS on and the table in step 1's 200 list (permissive grants instead), and permissive_exists (a policy that permits everyone). This census is also the fastest way to spot which tables no one ever touched.

Step 3 — the two bypass paths (3 minutes)

RLS on the base table is not the whole surface. Two constructs route around it:

Step 4 — storage (2 minutes)

Two separate settings: the bucket's public flag, and the policies on storage.objects. A private bucket with an anon upload policy on a user-scoped path is still a write leak; a public bucket with carefully scoped policies still serves every file to anyone with the URL. Check both, and test an anon upload into a user-scoped folder path — storage.objects insert policies are where overwrite permissions hide.

Step 5 — grants (2 minutes)

Policies filter rows; grants decide whether the role reaches the table at all. The audit-relevant question is not "which grants exist" but "which grants should not": write access granted to anon, or table grants added outside migrations. Compare live grants against what your migration files define — drift between the two is a finding of its own.

Step 6 — the bundle scan (1 minute)

grep -rE "service_role|SERVICE_ROLE" .next/static src/ 2>/dev/null
grep -rE "NEXT_PUBLIC_[A-Z_]*(KEY|SECRET|TOKEN)" src/ | grep -v PUBLISHABLE

A service role key in a client bundle or a NEXT_PUBLIC_ variable is a critical finding that no amount of database testing can catch — the posture is decided by what shipped, not by what the policies allow.

What the first 15 minutes settles — and what it doesn't

Settled: whether anon can read user-scoped data, whether RLS is enabled and restrictive everywhere it matters, whether definer functions and views bypass it, whether storage is open to writes it shouldn't be, and whether a privileged key shipped to the client. That is the majority of critical findings.

Not settled: the full two-tenant matrix across every table, behavior of the write path under race conditions, grant drift across migration history, and whether the fix you deploy survives the next migration. Those need the matrix and the migration-history review — which is the paid audit, not the 15 minutes.

Run the same sequence against your own project

The steps above, as seven commented SQL files

Supabase RLS Audit Kit · $29 one-time

The anon-key probe, the policy census, definer and view detection, storage and grant checks, and the role-simulation harness — 60 checks with report and fix-SQL templates. The commercial license covers every project you run it on.

Get the kit - $29 ↗Review the files, scope and license →

Related reading: why your RLS tests pass and your data still leaks, 7 ways your Supabase app still leaks data with RLS enabled and what your AI security reviewer structurally cannot see. If the probe above returns rows you did not expect, a fixed-price Supabase RLS audit from $99 returns the full matrix with reproducible evidence and fix SQL.

Template bundle

Review all 20 frontend starters in one bundle

20 editable frontend starters with live demos are $79 — one payment, no subscription.

View the Bundle — $79↗