How to test Supabase RLS (signed out, then as two users)
RLS being on doesn't prove your data is safe. Test it like a stranger: signed out, then as user A going after user B's rows. Copy-paste curl and SQL.
Check your live site
Run the free ShipLatch scan on your own site without leaving this page.
Uses 1 of your 3 free scans today; a site scanned in the last 24 hours answers from that scan. Passive and read-only: we only look at what any browser can see. Only check sites you own or may test.
To test Supabase RLS, call your own project's API the way a stranger would. First use only the public anon key, then sign in as one test user and try to read, change and delete a second user's rows. A safe table answers with an empty list or a permission error. Any row you don't own means the policy leaks, even if the dashboard says RLS is on.
Not sure a key that skips RLS isn't already sitting in your live JavaScript? Scan free.
Why "RLS enabled" isn't a test
Two things guard every request. Grants decide whether a role (anon when signed out, authenticated when signed in) can touch a table at all; a missing grant gives a permission error. RLS policies decide which rows that role sees; a policy that matches nothing gives an empty result (Supabase docs). RLS on with using (true) still hands every row to anyone.
This is not theoretical. In the write-up behind CVE-2025-48757, the researchers reported 303 exposed endpoints across 170 of 1,645 Lovable projects they checked. On one site, RLS looked active for signed-in requests, but removing the Authorization header gave read access and let them insert a row marked as paid (researchers' statement). A signed-out test catches exactly that.
How to test it, step by step
Only run these against a project you own, with throwaway test rows.
- Set up two test users. Create users A and B and make one row per table as each. Copy your project URL and anon (publishable) key from the API settings. Empty tables always return
[], so seed them first. - Read every table signed out. Send only the key, with no Authorization header:
Private tables should giveTerminal
URL="https://YOUR_PROJECT.supabase.co" ANON="YOUR_ANON_KEY" curl -s "$URL/rest/v1/notes?select=*&limit=5" -H "apikey: $ANON"
[]or a42501error. Rows mean anyone on the internet can read them. - Try to write signed out. Ask for the result back so you can see whether anything changed:
You want an error, not a new row.Terminal
curl -s -X POST "$URL/rest/v1/notes" -H "apikey: $ANON" \ -H "Content-Type: application/json" -H "Prefer: return=representation" \ -d '{"body":"rls-test"}' - Get user A's token. If you use email and password, sign in through the Auth API:
CopyTerminal
curl -s "$URL/auth/v1/token?grant_type=password" -H "apikey: $ANON" \ -H "Content-Type: application/json" \ -d '{"email":"a@example.com","password":"TEST_PASSWORD"}'access_tokenfrom the reply intoTOKEN_A. You can also copy it from your browser's dev tools while signed in as A. - As A, go after B's rows. Read, update and delete by B's ids. Always filter by id on writes:
Each should returnTerminal
curl -s "$URL/rest/v1/notes?select=*&user_id=eq.USER_B_ID" \ -H "apikey: $ANON" -H "Authorization: Bearer $TOKEN_A" curl -s -X PATCH "$URL/rest/v1/notes?id=eq.B_ROW_ID" \ -H "apikey: $ANON" -H "Authorization: Bearer $TOKEN_A" \ -H "Content-Type: application/json" -H "Prefer: return=representation" \ -d '{"body":"edited by A"}'[]. Repeat withDELETE, and try inserting a row with B'suser_id; a correctwith checkrejects it. - Prove A still works. Read A's own rows with A's token. If A gets
[]too, the table has no working policy: your app is broken, not protected. - Cover what tables don't. Views run with their creator's rights and skip RLS by default unless they are created with
security_invoker = on(Supabase RLS docs). Call any database functions your app uses at/rest/v1/rpc/namesigned out, and check storage buckets. Then lock the results in with pgTAP andsupabase test dbso the next AI edit can't quietly undo them (Supabase pgTAP guide).
Reading the answers
- Rows you don't own: leak. RLS is off, a policy is
using (true), or it checks login but not ownership. [], and the owner can still read their row: pass.[]for everyone, owner included: no matching policy. Don't "fix" it withusing (true).42501 permission denied for table: no grant for that role. Since October 30, 2026, new tables inpublicneed an explicit grant before the API can reach them; existing tables keep theirs (Supabase changelog).new row violates row-level security policy: yourwith checkblocked the write. Pass.
Which test proves what
| Method | What it tells you | What it misses |
|---|---|---|
| RLS toggle in the dashboard | RLS is on for that table | Whether the policy is right |
| Security Advisor | Tables with RLS off, always-true policies, risky views and functions | Which data your app means to keep private |
| Signed-out curl | What any stranger with your anon key can read or write | Cross-user leaks between signed-in users |
| Two-user curl | Whether user A can reach user B's rows | Tables and functions you forgot to test |
SQL Editor with set local role | Fast policy checks without tokens | Grants and API settings in front of the database |
pgTAP (supabase test db) | Repeatable checks after every change | Only the cases you wrote tests for |
| ShipLatch free scan | Whether a key that bypasses RLS is in your live page or scripts | Your tables (it never queries your database) |
Checklist
- Every table in
publichas RLS enabled. - Signed out, private tables return
[]or42501, for reads and writes. - User A can't read, update, delete or insert as user B.
- User A can still read A's own rows.
- No
using (true)on tables with personal data. - Views use
security_invoker = on; functions your app calls were tested signed out. - No
service_roleorsb_secret_key in your frontend. - pgTAP tests re-run after every schema change.
Write a pgTAP test file under supabase/tests/ for every table in the public schema. For each table, assert that anon cannot select, insert, update or delete private rows, that authenticated user A cannot read, update, delete or insert rows owned by user B, and that A can still read A's own rows. Do not change any policy to make a test pass; report which test failed instead.
FAQ
Is turning RLS on enough?
No. RLS on with a policy like using (true) still lets everyone read every row. RLS on with no policy blocks your own app. You only know a policy is right after you test it as a stranger and as a second user.
Why does my test return [] even for my own rows?
Either the table is empty, the row belongs to someone else, or RLS is on with no policy that matches. Sign in as the row's owner and read it. If the owner also gets [], add or fix the select policy.
What does "permission denied for table" (42501) mean?
The role has no grant on that table, so Postgres refused before RLS ran. Signed out, that is fine for private tables. If your app needs the table, grant only the operations it needs and keep RLS on.
Can I test RLS in the Supabase SQL Editor?
Yes, but switch roles first. Inside begin and rollback, run set local role authenticated and set request.jwt.claims with a test user's id. A plain select runs as the postgres role, which owns your tables and skips your policies.
Is it a problem that my anon key is in the frontend?
No. The anon or publishable key is meant to be public, and RLS decides what it can reach. The service_role key and sb_secret_ keys bypass RLS completely, so they must never be in your frontend.
Check the part you can't see from the dashboard
ShipLatch checks what your live site hands out: Supabase keys that bypass RLS (service_role, sb_secret_), other leaked API keys in your page and scripts, and exposed /.env and /.git. Free and passive; it never queries your database.
Related: Supabase RLS policy examples · Supabase Security Advisor warnings, explained · Supabase exposure check
Sources (all fetched 2026-10-30)
- Supabase, API keys: https://supabase.com/docs/guides/getting-started/api-keys
- Supabase, Row Level Security: https://supabase.com/docs/guides/database/postgres/row-level-security
- Supabase, Testing RLS with pgTAP: https://supabase.com/docs/guides/database/postgres/testing-row-level-security
- Supabase changelog, tables not exposed automatically: https://supabase.com/changelog/45329-breaking-change-tables-not-exposed-to-data-and-graphql-api-automatically
- NVD, CVE-2025-48757: https://nvd.nist.gov/vuln/detail/CVE-2025-48757
- Statement on CVE-2025-48757: https://mattpalmer.io/posts/statement-on-CVE-2025-48757/
Want the whole pre-launch process in one place? Get App Security for Vibe Coders, our $9 PDF playbook. See what's inside.