DenyFirst

DenyFirst blog

Supabase hands you a public API. RLS decides who it answers.

2026-08-09

Supabase is a joy to build on precisely because it does so much for you. Create a table and you instantly get a REST API over it, courtesy of PostgREST, reachable with the anon key that ships in your client bundle. That key isn't a secret — it's *designed* to be public. Which means the only thing standing between your data and the entire internet is Row Level Security.

And RLS is a policy layer you have to opt into, table by table. That gap — between "the API exists the moment the table does" and "the policy that guards it exists only when you write it" — is where a large share of Supabase breaches live.

What "anonymous" actually means

When people hear "the anon role can read this table," they picture some internal service account. It isn't. anon is *the unauthenticated public*. A permissive or missing RLS policy on a table means anyone who knows your project URL — printed in your own frontend — can do this:

curl 'https://<ref>.supabase.co/rest/v1/profiles?select=*' \
  -H "apikey: <the anon key from your bundle>"

…and get rows back. Not test rows. Production rows. Every row the policy allows, which for an unguarded table is all of them.

The write side is worse and more surprising. If RLS is off, PostgREST will honor an anonymous POST, PATCH, or DELETE too. "Read-only leak" quietly becomes "anyone can insert or tamper with your data."

The three ways it goes wrong

1. RLS never enabled. The table works in the app, so nobody notices the API is wide open. This is the classic one. 2. RLS enabled, policy too broad. using (true) looks like a policy and passes review, but it authorizes everyone. Same outcome as no RLS, with the comfort of thinking you're protected. 3. RLS on the table, but data reachable another way — a view, an RPC, or a related table that isn't guarded. The lock is on the front door; the window is open.

The through-line is the same as every other backend: being able to reach the API is not permission to read the data. Authentication (or the lack of it) is a separate question from authorization.

Prove it without reading anyone's data

You don't need to exfiltrate rows to know a table is exposed — you need to know whether an anonymous request is *accepted*. A count-only request answers that:

curl -I 'https://<ref>.supabase.co/rest/v1/orders?select=count' \
  -H "apikey: <anon key>" -H "Prefer: count=exact"

The response headers tell you the row count without returning a single record. If an anonymous caller gets a count back, an anonymous caller can get the rows. That's the whole finding — reachability, proven, without touching the contents.

The honest audit also has to *discover* which tables are exposed, not just check the ones you remember. PostgREST publishes an OpenAPI description at the project root; the tables the anon role can see are listed there. The ones you forgot are exactly the ones that leak.

The fix

Enable RLS on every table that holds real data, and write policies that scope each row to its owner or tenant:

alter table profiles enable row level security;

create policy "own profile" on profiles
  for select using (auth.uid() = user_id);

Then audit for using (true), check your views and RPCs, and confirm that a freshly-created anonymous request gets denied, not counted.


DenyFirst discovers the tables your Supabase project actually exposes to the anon role and proves — count-only, never reading a row — which ones answer an unauthenticated request. If nothing opens, you pay nothing. See if we can get in →


← All posts