Testing RLS the Right Way: anon, owner, and other-user
Learn how to test Supabase RLS for real using three personas — anon, owner, and other-user — with copy-paste curl and supabase-js examples. See a pass vs a leak.
You enabled Row Level Security on your tables, the dashboard shows a green "RLS enabled" badge, and you'd like to believe you're done. Here's the uncomfortable truth: that badge proves the feature is switched on, not that it actually protects anything. A table can show RLS enabled and still hand every row to a stranger.
The only way to know your data is safe is to test RLS the way an attacker would — by actually trying to read data you shouldn't be able to read. That sounds technical, but it comes down to three simple roles, or "personas," and a few copy-pasteable commands.
This post shows you how to test Supabase RLS using those three personas — anonymous, owner, and other-user — with examples you can run yourself. You'll see exactly what a pass looks like and exactly what a leak looks like.
⚡ TL;DR
- "RLS enabled" in the dashboard is not proof your data is safe. A policy of
USING (true)shows as enabled and still leaks every row.- Test with three personas: anon (no login), owner (you, reading your own row), and other-user (a different account trying to read your row). Owner should succeed; anon and other-user should get nothing private.
- We give copy-paste
curlandsupabase-jsexamples for each, and show what a pass versus a leak looks like.
Why "RLS enabled" is not proof
Row Level Security (RLS = the database rule that decides which rows each user can see) has two parts: the on/off switch, and the policy that actually defines the rule. The dashboard badge only tells you about the switch.
The classic trap is a policy written as USING (true). In plain terms, that policy says "this rule matches every row" — which is the same as no protection at all. The table shows "RLS enabled," looks locked in every dashboard view, and serves all your data to anyone who asks. (We dig into this specific failure in the USING(true) trap.)
This is why reading config is not enough and you have to test behavior. The question isn't "is RLS turned on?" It's "when an unauthorized person queries this table, do they get blocked?" The only honest way to answer that is to send the query and look at what comes back — which is what the three personas are for.
If you're new to how RLS works at all, read Supabase RLS explained first — this post assumes you know the concept and want to verify it.
The three personas
Good RLS testing means impersonating three different kinds of visitor and checking that each one sees only what they should.
- anon (anonymous): a visitor with no login at all — only your public anon key. They should see only data you've intentionally made public. They should never see private user data.
- owner: a logged-in user reading their own rows. This one should succeed. If the owner can't see their own data, your policy is too strict and your app is broken (which is a real failure too, only a different one).
- other-user: a different logged-in user trying to read the first user's rows. This is the most important test. They should get nothing. If user B can read user A's private records, you have a leak — exactly the kind behind the documented RLS incidents in this space.
A correctly protected table gives you this result: owner sees their row, anon sees nothing private, other-user sees nothing private. Any other outcome on a table with personal data is a problem.
Testing with curl
curl is a command-line tool for making web requests, and it's the most direct way to hit your Supabase REST endpoint as each persona. Supabase exposes every table at https://YOUR_PROJECT.supabase.co/rest/v1/TABLE_NAME. You'll need your project URL, your public anon key, and a couple of test accounts.
⚠️ Watch out
Run these tests with your anon key, never your service_role key. The service_role key bypasses RLS by design, so testing with it will make everything look "accessible" and tell you nothing. If you're unsure which key is which, see service_role vs anon key.
Persona 1 — anon (no login). Send only the anon key, no user token:
curl "https://YOUR_PROJECT.supabase.co/rest/v1/profiles?select=*" \
-H "apikey: YOUR_ANON_KEY"
A protected table returns an empty list [] (or only truly public rows). If this returns other users' private profiles, anyone on the internet can read them.
Persona 2 — owner (your own row). First get a user access token by logging in, then send it as a Bearer token:
# Step 1: log in as user A to get an access token
curl "https://YOUR_PROJECT.supabase.co/auth/v1/token?grant_type=password" \
-H "apikey: YOUR_ANON_KEY" \
-H "Content-Type: application/json" \
-d '{ "email": "userA@example.com", "password": "userA-password" }'
# → copy the "access_token" from the response
# Step 2: query as user A
curl "https://YOUR_PROJECT.supabase.co/rest/v1/profiles?select=*" \
-H "apikey: YOUR_ANON_KEY" \
-H "Authorization: Bearer USER_A_ACCESS_TOKEN"
This should return user A's own row. That's the pass — the owner can see their own data.
Persona 3 — other-user (someone else's row). Log in as user B, then try to read user A's data:
# Logged in as user B, try to fetch user A's specific row
curl "https://YOUR_PROJECT.supabase.co/rest/v1/profiles?select=*&user_id=eq.USER_A_ID" \
-H "apikey: YOUR_ANON_KEY" \
-H "Authorization: Bearer USER_B_ACCESS_TOKEN"
A protected table returns [] here — user B is blocked from user A's row. This is the test that matters most. If user B gets user A's data back, RLS is not protecting you, no matter what the dashboard says.
Testing with supabase-js
If you'd rather test in JavaScript, the supabase-js client does the same three checks. This mirrors how your real app talks to the database, so it's a faithful test.
import { createClient } from '@supabase/supabase-js';
const url = 'https://YOUR_PROJECT.supabase.co';
const anonKey = 'YOUR_ANON_KEY';
// --- Persona 1: anon (no login) ---
const anon = createClient(url, anonKey);
const { data: anonData } = await anon.from('profiles').select('*');
console.log('anon sees:', anonData); // expect [] or only public rows
// --- Persona 2: owner (user A) ---
const owner = createClient(url, anonKey);
await owner.auth.signInWithPassword({
email: 'userA@example.com',
password: 'userA-password',
});
const { data: ownerData } = await owner.from('profiles').select('*');
console.log('owner sees:', ownerData); // expect user A's own row(s)
// --- Persona 3: other-user (user B reading user A) ---
const other = createClient(url, anonKey);
await other.auth.signInWithPassword({
email: 'userB@example.com',
password: 'userB-password',
});
const { data: otherData } = await other
.from('profiles')
.select('*')
.eq('user_id', 'USER_A_ID');
console.log('other-user sees:', otherData); // expect [] — the critical check
Run it and read the three log lines. The pattern you want is: owner sees their row, anon and other-user see nothing private.
What a pass vs a leak looks like
Here's the at-a-glance result for a properly protected table versus a leaking one.
A pass (RLS working):
- anon →
[](empty, or only public rows) - owner → their own row(s) returned
- other-user →
[](empty)
A leak (RLS not protecting you):
- anon → returns private rows belonging to real users
- owner → their own row(s) returned (this part still looks normal, which is what makes it sneaky)
- other-user → returns user A's private rows
Notice that in the leak case, the owner test still passes. That's the heart of why people miss this: the app works perfectly for the logged-in user, so everything seems fine. The damage only shows up when you try the anon and other-user reads. If you only ever test as yourself, you will never see the leak.
How to check your own app
To put this into practice quickly:
- Create a second test account. You need at least two users to run the other-user test. A throwaway second email is enough.
- Pick your most sensitive table — the one with user profiles, messages, orders, or anything personal — and run all three personas against it using either method above.
- Read the results literally. If anon or other-user returns anything that isn't meant to be public, that's a confirmed leak. Fix it before moving on.
- Repeat for every table with user data. RLS is per-table. One protected table tells you nothing about the next one.
Not sure if your app has this exact issue?
Run a free, read-only scan of your live app — no install, results in under a minute.
Scan my app free →The fix when a persona test fails
If anon or other-user returned data it shouldn't, the table either has RLS off or a USING(true) policy. Replace it with a policy that ties each row to its owner:
-- Make sure RLS is actually on
alter table profiles enable row level security;
-- Read only your own row (replace any USING(true) policy with this)
create policy "Users read own profile"
on profiles for select
using ( auth.uid() = user_id );
The using ( auth.uid() = user_id ) line is the real per-user filter. After applying it, re-run all three personas. Don't trust that the change worked — confirm it. The owner test should still pass, and the anon and other-user tests should now return []. Testing isn't a one-time gate; re-test every time you change a policy or add a table.
FAQ
Why can't I trust the "RLS enabled" badge in the dashboard?
Because the badge only confirms the feature is switched on, not that the policy behind it actually restricts rows. A policy of USING (true) shows as enabled and leaks every row. Behavior is the only reliable signal — you have to send an unauthorized query and confirm it comes back empty.
Do I need a second account to test RLS properly?
For the most important test, yes. The other-user persona — a different account trying to read your data — is what catches a leak where any logged-in user can see any other user's rows. The anon test catches fully public leaks, but only the other-user test catches cross-user ones. A throwaway second account is enough.
Can I test RLS with the service_role key to make it easier?
No — that defeats the purpose. The service_role key bypasses RLS entirely, so every query will succeed and you'll learn nothing about whether your policies work. Always test with the public anon key (plus user logins for the owner and other-user personas), because that's what real visitors use.
The bottom line
A green "RLS enabled" badge is not proof your data is safe — a USING(true) policy wears the same badge and leaks everything. The only honest test is to behave like the three personas: anon, owner, and other-user, and confirm that owner succeeds while anon and other-user come back empty. Run those checks on every table with personal data, and re-run them whenever you change a policy. An attacker can run the other-user test against your app in minutes — the point is to run it first, and to keep running it, because every new feature your AI ships adds a table that needs the same three checks.
Find your gaps before an attacker does.
Is My Site Hackable? scans your deployed app for the exact issues in this article — exposed keys, missing RLS, open buckets — and tells you what's real and what's a false alarm.
Run a free scan →