Your Supabase RLS Policy Might Be Fine and You Have No Way of Knowing
A blocked write returns 204 No Content, which looks exactly like success. We ran the full verification against a live project and found 3 things the dashboard will not tell you.
The WJS Desk
Aug 31, 2026 · updated 4 hours ago · 8 min read

You can ship a Supabase app with row level security switched on, a policy in place, your tests green, and still be leaking every draft row to anyone who opens devtools. We know because the failure mode looks exactly like success.
This is the verification procedure we now run before any Supabase project goes near the internet. It takes about fifteen minutes, it is entirely curl, and it catches three things the dashboard will not tell you.
Why the usual check is not enough
The normal way people verify RLS is to open the app in a private window and see whether drafts show up. That tests your application code. It does not test the database, and your database is directly reachable.
The anon key ships in your browser bundle. Anyone can extract it and query the REST endpoint without going anywhere near your frontend. So the only verification that counts is hitting PostgREST with the anon key by hand.
We ran the full sequence below against a live project. Here is what the anon key could do at each stage:
| Stage | anon can read drafts | anon can write |
|---|---|---|
| Table created, RLS off | Yes, everything | Yes, 201 Created |
| RLS on, no policies | No, returns [] | No, 401 |
| RLS on, SELECT policy | No, returns [] | No rows affected |
That first row is the one to sit with. A freshly created table with no RLS is world-readable and world-writable through the REST API the moment it exists.
Prerequisites
A Supabase project, the Supabase CLI linked to it, and both keys to hand. You need the anon key and the service role key, and you need to keep them straight, because the whole exercise is comparing what each one can do.
export URL="https://YOUR-PROJECT.supabase.co"
export ANON="your-anon-key"
export SVC="your-service-role-key"Budget fifteen minutes. Each migration push in our run took under three seconds.
Step 1: prove the danger is real
Create a table with no RLS. Two rows, one published and one draft.
create table rls_demo (
id serial primary key,
title text not null,
status text not null default 'draft'
);
insert into rls_demo (title, status) values
('public row','published'),
('secret row','draft');Push it, then read it with the anon key:
curl -s "$URL/rest/v1/rls_demo?select=title,status" \
-H "apikey: $ANON" -H "Authorization: Bearer $ANON"Ours returned both rows, including the draft. Then we tried writing:
curl -s -o /dev/null -w "%{http_code}\n" -X POST "$URL/rest/v1/rls_demo" \
-H "apikey: $ANON" -H "Authorization: Bearer $ANON" \
-H "Content-Type: application/json" \
-d '{"title":"anon wrote this","status":"published"}'201. An anonymous request created a row. This is the default state of every new table until you do something about it.
If you see PGRST205 instead: PostgREST has not picked up the new table yet. Its schema cache lags the migration by a few seconds. Wait and retry rather than assuming the table failed to create, which is what we did the first time.
Step 2: turn RLS on and check the deny-by-default
alter table rls_demo enable row level security;That is the whole change. Now re-run both curls. Ours returned [] for the read and 401 for the write, while the service role key still saw all three rows.
This is the important intermediate state. RLS enabled with zero policies denies everything to anon and changes nothing for service role. Deny by default is the correct starting point, and you should confirm you are in it before adding a single policy.
Step 3: add exactly one policy
create policy "public reads published rows"
on rls_demo for select
to anon
using (status = 'published');Note what is absent. No INSERT policy, no UPDATE policy, no DELETE policy. With RLS on, an operation with no matching policy is denied, so writing nothing is how you deny writes.
Verify both directions. Not just that the published row appears, but that the draft still does not:
curl -s "$URL/rest/v1/rls_demo?select=title,status" \
-H "apikey: $ANON" -H "Authorization: Bearer $ANON"
curl -s "$URL/rest/v1/rls_demo?status=eq.draft&select=title" \
-H "apikey: $ANON" -H "Authorization: Bearer $ANON"The first returned only published rows. The second returned []. Both matter: the second proves the row is being filtered rather than merely absent from your test data.
What broke: the 204 that means nothing happened
Here is the failure we did not expect, and the reason this article exists.
We tried an anonymous UPDATE against the draft row, expecting a 401 or 403 like the INSERT gave us:
curl -s -o /dev/null -w "%{http_code}\n" \
-X PATCH "$URL/rest/v1/rls_demo?title=eq.secret%20row" \
-H "apikey: $ANON" -H "Authorization: Bearer $ANON" \
-H "Content-Type: application/json" \
-d '{"status":"published"}'It returned 204 No Content. In most APIs that is success.
Nothing had changed. Checking with the service role key, the draft was still a draft. DELETE behaves identically: 204, and the row is still there.
The explanation is that RLS filters rows rather than rejecting requests. The UPDATE ran against zero visible rows, updated zero rows, and reported success at doing so. PostgREST is not lying, but if you are reading status codes to decide whether your policy works, you will read that 204 as a hole in your security.
A 204 on a blocked write does not mean the write succeeded. It means zero rows matched, and those look identical from the outside.
The fix is to ask PostgREST what it actually touched:
curl -s -X PATCH "$URL/rest/v1/rls_demo?title=eq.secret%20row" \
-H "apikey: $ANON" -H "Authorization: Bearer $ANON" \
-H "Content-Type: application/json" \
-H "Prefer: return=representation" \
-d '{"status":"published"}'With that header the body comes back as [], an explicit empty list of affected rows. That is the unambiguous signal, and it is what we now assert on.
Practical tip: add Prefer: return=representation to every write in your RLS test suite. It converts an ambiguous 204 into a countable result, and it costs nothing.
Common mistakes
- Testing with the service role key by accident. It bypasses RLS entirely, so every test passes and none of them mean anything. If your checks never fail, confirm which key you are sending.
- Only checking that allowed data appears. Seeing the published row proves the policy is not too strict. It says nothing about whether it is too loose.
- Forgetting join tables. Your articles table can be locked down while the tags join is wide open, leaking which hidden rows exist and what they are about.
- Assuming a table with no policies is safe because it has no policies. Safe requires RLS enabled. Without that, no policies means no restrictions.
- Reading a 204 as a successful write. The subject of the section above.
Rolling back
Everything here is reversible in one statement, which is what makes it safe to try on a real project:
alter table rls_demo disable row level security;
drop policy "public reads published rows" on rls_demo;We ran this whole sequence against a live project using a scratch table, then dropped it. One thing to know if you do the same: deleting the scratch migration files locally leaves orphaned entries in the remote migration history. Clean those up rather than leaving the mismatch:
supabase migration list
supabase migration repair --status reverted 9001Automating it
A procedure you run by hand before launch is a procedure you run once. We turned ours into a script that runs before every deploy, and it is short enough to reproduce in full.
The three things it asserts are the three things that would be catastrophic: unpublished content is unreachable, operational tables are unreachable, and anonymous writes affect zero rows.
#!/usr/bin/env bash
set -uo pipefail
URL=$(grep '^NEXT_PUBLIC_SUPABASE_URL=' apps/web/.env | cut -d= -f2-)
ANON=$(grep '^NEXT_PUBLIC_SUPABASE_ANON_KEY=' apps/web/.env | cut -d= -f2-)
fail=0
check() {
if [ "$2" = "$3" ]; then echo " ok $1"
else echo " FAIL $1: expected $2, got $3"; fail=1; fi
}
get() { curl -s "$URL/rest/v1/$1" -H "apikey: $ANON" -H "Authorization: Bearer $ANON"; }
check "anon cannot read review articles" "[]" "$(get 'articles?status=eq.review&select=id')"
check "anon cannot read pipeline_runs" "[]" "$(get 'pipeline_runs?select=id')"
w() {
curl -s -X "$1" "$URL/rest/v1/$2" \
-H "apikey: $ANON" -H "Authorization: Bearer $ANON" \
-H "Content-Type: application/json" -H "Prefer: return=representation" \
${3:+-d "$3"}
}
check "anon UPDATE affects no rows" "[]" "$(w PATCH 'articles?status=eq.review' '{"status":"published"}')"
check "anon DELETE affects no rows" "[]" "$(w DELETE 'articles?status=eq.review')"
exit "$fail"Two details make it worth having rather than just feeling responsible. It reads the keys from the same env file the deployed app uses, so it cannot accidentally test with the service role key and pass for the wrong reason. And every write assertion uses Prefer: return=representation, so it compares against [] rather than a status code.
Running it against our own project takes under two seconds and prints one line per assertion:
ok anon cannot read review articles
ok anon cannot read drafts
ok anon cannot read archived
ok anon cannot read pipeline_runs
ok anon cannot read ad revenue
ok anon UPDATE affects no rows
ok anon DELETE affects no rows
ok anon can read published articles
RLS verified.The last assertion is the one people leave out, and it is there because the failure mode of an over-tight policy is a site that silently shows nothing. A check that only ever tests for absence will happily pass on a completely broken site.
The join table nobody checks
Worth calling out separately because it is the most common real leak we see.
Suppose articles is locked down properly and you also have article_tags joining articles to tags. If you enabled RLS on the first and forgot the second, the join is readable. An attacker cannot read your unpublished article, but they can enumerate how many unpublished articles exist and which tags they carry, which for an embargoed announcement is most of what they wanted.
The policy has to reach through the join:
create policy "public reads tags of published articles"
on article_tags for select
to anon
using (exists (
select 1 from articles a
where a.id = article_tags.article_id
and a.status = 'published'
));Then assert it. Add every join table to the script, not just the tables holding the content you were thinking about.
What we would not do yet
This procedure covers the anon role only. If you use Supabase Auth with authenticated users and per-user policies, you need the same sequence run with a real user JWT, and the interesting failures there are different: usually a policy that reads auth.uid() correctly in isolation but is bypassed through a join.
We also have not covered performance. Policies with subqueries run per row, and on a large table a naive exists clause will cost you. That is a separate afternoon.
Next steps
Turn this into something that runs automatically. Ours is a shell script in the repo that asserts the anon key gets [] for unpublished rows, and it runs before every deploy. It is fifteen lines and it is the only test we have that would catch a catastrophic mistake.
Then go and check the tables you already shipped. Specifically the join tables, because those are the ones people forget.


