Supabase RLS Gaps: The Backend Holes a Front-End Scan Never Sees
The anon key is public by design
Supabase gives you an "anon" key that ships inside your front-end JavaScript. It is meant to be public. It is not a secret, and it is not a permission. The only thing standing between that key and your entire database is Row Level Security. If RLS is off, or a policy says USING (true), the key is a skeleton key that anyone who views source can copy.
That is the trap. Everything looks locked from the browser, because your UI only ever asks for the current user's data. The API underneath will happily answer for all of it.
The test: a read that touches nothing
Supabase exposes every table over PostgREST at /rest/v1/<table>. You can ask for a row count without pulling any data:
curl -s -I 'https://YOURREF.supabase.co/rest/v1/profiles?select=id' \
-H "apikey: eyJhbGci...your-anon-key" \
-H "Authorization: Bearer eyJhbGci...your-anon-key" \
-H "Range: 0-0" \
-H "Prefer: count=exact"Look at the Content-Range header in the response. Content-Range: 0-0/48213 means the anonymous role can see 48,213 profile rows. On a properly locked private table it should read 0-0/0, or the request should come back 401.
Only run this against a project you own. It is read-only and counts rows without returning them, but "test only what is yours" is not optional. This is a check on your own exposure, not a tool for poking at other people's Supabase projects.
Read is bad, write is worse
The same key, if the policies are missing, will accept an INSERT, UPDATE, or DELETE. You do not need to prove destruction to know you are exposed. A write that returns 201 on a table that should reject anonymous writes is proof enough:
POST /rest/v1/waitlist HTTP/1.1
Host: yourref.supabase.co
apikey: eyJhbGci...your-anon-key
Content-Type: application/json
Prefer: return=representation
{"email":"probe@notra.test","note":"rls check"}A 201 Created means anonymous writes are open. Use a throwaway row you can remove, or better, run this against a staging project. Never run destructive writes to "confirm" a finding on data you cannot restore.
The subtle version: RLS on, policy wrong
The obvious gap is RLS switched off entirely. The one that survives a quick review is RLS on with a policy that is too generous. A team enables RLS, sees the checkbox go green, and writes a policy that reads plausibly but grants everything:
-- Looks locked. Is not. This allows any authenticated user
-- to read every row, because the condition is always true.
create policy "authenticated read"
on orders for select
to authenticated
using (true);Two other traps in the same family. First, a policy that covers SELECT but leaves INSERT, UPDATE, and DELETE with no policy at all, which on some setups fails open depending on your grants. Second, forgetting that the anon role and the authenticated role are different, and writing a tight policy for one while leaving the other wide. The count probe above catches all of these, because it asks the database the only question that matters: given this key, how many rows can I actually see?
Why a front-end scanner never sees this
A DAST tool crawls your rendered pages and pokes at form fields. Your Postgres policies are not in the HTML. The scanner has no model of "table," "row," or "policy," so a wide-open profiles table is simply invisible to it. It is a high-impact, trivially exploitable bug that lives entirely outside the tool's worldview. That is the exact blind spot we describe in Why Your Scanner Lies: the number a scanner gives you can be low and clean while the real hole is wide open.
Once an attacker can read your users, the next question is whether they can also become one of your admins. If your JWT signing secret is anywhere near your bundle, see Forging Admin.
Fixing it
- Turn RLS on for every table in the
publicschema. RLS off is the default trap, not a safe starting point.
-- The policy that turns the skeleton key back into a locked door
alter table profiles enable row level security;
create policy "own rows only"
on profiles for select
using (auth.uid() = id);- Write policies that reference
auth.uid(), nottrue. "Users see their own rows" isUSING (auth.uid() = user_id), not a blanket allow. - Keep the
service_rolekey server-side only. It bypasses RLS entirely and belongs nowhere near a browser. - Re-run the count probe afterward.
0-0/0for the anon role on a private table is the goal.
RLS is a backend control, so it needs a backend test. Notra checks your /rest/v1 surface the way an attacker would, non-destructively, and shows you the Content-Range header proving what the anon key can reach. Run a free scorecard, or read more on the blog.