notra·
2026-08-27·4 min read·Notra·Reviewed 2026-09-09

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 when the response includes a count. Content-Range: 0-0/48213 means the request's role can see 48,213 profile rows. A locked table may instead return an empty range or an authorization error; exact status and range depend on grants, policies, and API configuration. See PostgREST's count documentation.

Warning

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

If grants and policies allow it, the same key may 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 without the intended grants or policies. With RLS enabled, a missing matching policy denies rows; grants and policies must be reviewed together. Second, forgetting that the anon role and the authenticated role are different, and writing a tight policy for one while leaving the other wide. Supabase explains this interaction in its RLS guide.

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

  1. Verify RLS on every exposed table in the public schema. Dashboard-created tables have RLS enabled by default, while tables created through SQL or other tools still need explicit review.
-- 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);
  1. Write policies that reference auth.uid(), not true. "Users see their own rows" is USING (auth.uid() = user_id), not a blanket allow.
  2. Keep the service_role key server-side only. It bypasses RLS entirely and belongs nowhere near a browser.
  3. Re-run the count probe afterward and confirm the anon role cannot access private rows; 0-0/0 is one possible response when exact counting is enabled.

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.

ShareX LinkedIn

Want this run against your site?