// GUIDE
What is Supabase Row Level Security, and why does it decide whether your app is safe?
Updated 2026-08-07 · 12 min read
Row Level Security (RLS) is a bouncer standing at every single row of your database, deciding per-request whether the person asking is allowed to see or change that row. Supabase puts your database on the public internet and hands your app a public "anon" key, that arrangement is safe only when RLS is turned on and every table has a correct policy. With RLS off, or a policy that says "allow everyone," that public key can read and write all of your data. This guide explains RLS in plain English, shows a good policy next to a dangerous one, and shows you how to verify yours.
The bouncer at every row
Imagine your database table is a members-only club and every row is a private booth. Row Level Security is a bouncer who stands at each booth and, every time someone asks to look inside, checks their ID against a rule: "you can only enter your own booth." That check happens on every request, for every row, no exceptions.
Without RLS, there is no bouncer. The doors are open. Anyone who walks up to the club, using nothing more than the public key that ships in your app, can walk into every booth and read (or rearrange) what is inside. RLS is the difference between "the database enforces who sees what" and "we just hope nobody looks."
Why the public "anon" key is safe, but only if RLS is right
Here is the part that trips up almost every founder. Supabase gives your frontend a key called the "anon" (anonymous) key, and that key is meant to be public, it ships in your browser code, and that is intentional and fine. People discover it in their network tab, panic, and assume they have been hacked.
The anon key is not a secret. It is more like the club's street address: knowing it just tells you where the door is. What actually keeps people out of the booths is the bouncer, RLS. So the anon key is completely safe to expose when RLS is on and your policies are correct, and completely dangerous when RLS is off, because then the address is all anyone needs to get into everything.
The key you must never expose is the "service_role" key, that one is a master key that walks past the bouncer entirely. It belongs only on your server, never in the browser.
Finding your anon key in the browser is expected and not a vulnerability by itself. Finding your service_role key in the browser is an emergency, rotate it immediately.
The two traps: "using (true)" and "I turned RLS off to make it work"
There are two ways AI-built Supabase apps end up wide open, and they are worth naming because they feel like solutions at the time.
The first is turning RLS off. A query returns nothing, the app is broken, and the fastest way to "fix" it is to disable Row Level Security on the table. The app instantly works, because now there is no bouncer at all. The fix was actually the disease.
The second is a policy of "using (true)". This looks like a real security policy, so it feels safe, but "true" means "this check passes for everyone, always." It is a bouncer who waves through every single person. It is functionally the same as having no policy, every row is readable by anyone with the anon key.
Both happen because they make the app work, and a working app gives you no warning that the door is open.
A dangerous policy next to a good one
Read the good example slowly: "using (auth.uid() = user_id)" is the bouncer's rule written out. "auth.uid()" is the ID of whoever is making the request; "user_id" is the column on the row that says who owns it. The row is only returned when those two match, you get your rows, and only your rows.
Notice the good policy is written per action (select, update) and includes a "with check" on writes so a user cannot save a row as if it belonged to someone else. You want a policy like this on every table that holds user data.
-- ❌ DANGEROUS: RLS turned off entirely.
-- Anyone with the public anon key can read and write every row.
alter table profiles disable row level security;
-- ❌ ALSO DANGEROUS: RLS is on, but the policy lets everyone through.
-- "using (true)" means the check passes for every request.
create policy "profiles are viewable"
on profiles for select
using (true);
-- ✅ GOOD: RLS on, and each user can only touch their OWN rows.
-- auth.uid() is the id of the logged-in user making the request;
-- user_id is the owner column on the row. They must match.
alter table profiles enable row level security;
create policy "users can read their own profile"
on profiles for select
using (auth.uid() = user_id);
create policy "users can update their own profile"
on profiles for update
using (auth.uid() = user_id)
with check (auth.uid() = user_id);How to verify your RLS is actually working
You do not have to take the dashboard's word for it. There are three checks, from easiest to most convincing.
First, in the Supabase dashboard open the Table Editor, it visibly flags any table where RLS is disabled. Every table with user data should show RLS enabled.
Second, open the "Authentication" and "Policies" views and confirm each table has policies that reference "auth.uid()" (or a role/ownership check), not "true".
Third, the real test: log out (or use an incognito window), and try to load data you should not be able to see. If your app can still fetch other users' rows while logged out or logged in as a different account, RLS is not doing its job. This last test is exactly what a scan automates, it asks your database for data it should refuse, and tells you what came back.
What a correct setup looks like end to end
- RLS is enabled on every table that holds user or business data, not just the obvious ones.
- Every table has policies scoped to ownership or role, using "auth.uid()", none rely on "using (true)".
- Write policies include a "with check" so users cannot create or move rows into someone else's account.
- The anon (publishable) key is the only Supabase key in your frontend; the service_role key lives only on the server.
- Sensitive operations that must bypass RLS run in a server-side function using the service_role key, never in the browser.
- You have tested access while logged out and as a second user, and confirmed you cannot reach data you should not see.
Key takeaways
- RLS is a per-row bouncer that checks, on every request, whether the requester is allowed to see or change that specific row.
- The public anon key is safe to expose only when RLS is on and policies are correct; with RLS off it is all an attacker needs.
- Never put the service_role key in the browser, it bypasses RLS entirely and is a master key to your database.
- "Turning RLS off to make it work" and a policy of "using (true)" are the two most common ways AI-built apps end up wide open.
- A good policy scopes rows to their owner with "auth.uid() = user_id" and adds "with check" on writes.
- Verify by trying to read data you should not be able to, logged out and as a second user, not by trusting the dashboard alone.
Frequently asked
Is it bad that my Supabase anon key is visible in the browser?
No, the anon key is designed to be public and shipping it in your frontend is normal. What makes it dangerous is having RLS turned off, because then the public key can reach all your data. Fix RLS, and the exposed anon key is a non-issue.
I turned RLS off because my app broke, is that OK?
No. Turning RLS off makes the app work by removing all row-level protection, so anyone with the anon key can read and write everything. The correct fix is to keep RLS on and write a policy that allows the specific access your app needs.
What does "using (true)" mean and why is it dangerous?
It is a policy whose condition is always true, so the check passes for every request from everyone. It looks like security but behaves like none, every row becomes readable by anyone with the anon key.
How do I know if my RLS policies are correct without being a developer?
Try to read data you should not have access to, log out or use a second account and see if your app still returns other people's rows. If it does, RLS is not protecting you. A free Supabase security scan runs this test for you and reports exactly what was reachable.