Supabase RLS: what it is and how to turn it on properly
Row level security (RLS) is a Postgres feature that decides, row by row, who can read or change data in a table. In Supabase the anon key ships inside your front end, so RLS policies are the only thing stopping anyone who copies that key from reading or editing every row.
Updated
Check your live site now
Free, no signup, read only. A grade and plain fixes in seconds.
What row level security is
Normal database permissions work per table: a role can read the orders table or it cannot. Row level security adds a second check per row. When RLS is enabled on a table, Postgres runs your policies against every row a query touches and silently drops the rows the policy does not allow.
A policy is a small SQL condition attached to a table. For example, "a signed in user can select a row from todos only when the row's user_id equals their own id". Postgres adds that condition to every query, including queries you did not write yourself. With RLS enabled and no policies at all, the anon and authenticated roles see nothing and can change nothing.
Supabase did not invent RLS. It is built into Postgres. Supabase leans on it heavily because of how its API works, which is the next part.
Why the public anon key makes RLS the only lock
Supabase gives every project a REST API at https://YOUR_PROJECT_REF.supabase.co/rest/v1/ that maps straight onto the tables in your public schema. Your front end talks to that API using the anon key (or the newer publishable key, which starts with sb_publishable_). That key is designed to be public. It sits in your JavaScript bundle, and anyone can copy it out of the browser's DevTools in a few seconds.
So picture someone who has your project URL and your anon key, which is anyone who has opened your site. They do not need your app at all. They can call the REST API directly and ask for any table. By default Supabase grants the anon and authenticated roles access to tables in the public schema, so the database will happily answer. The only thing that decides what comes back is RLS.
- RLS off on a table: anyone with the anon key can read, insert, update and delete every row in it.
- RLS on with no policies: the table is closed to the anon and authenticated roles. Your app sees nothing either, which is usually how people notice.
- RLS on with good policies: each user reaches exactly the rows you intended, whether they use your app or call the API by hand.
This setup is why AI app builders like Lovable and Bolt can wire a front end straight to a database with no backend code. It is also why a missing policy leaks data. The platform is secure. A table without RLS is simply a table you have published.
How to enable RLS on a Supabase table
Tables made in the Supabase Table Editor ask about RLS when you create them. Tables made in the SQL editor, in migrations, or by an AI agent writing SQL follow plain Postgres rules, which means RLS starts off. Turn it on for every table in the public schema:
alter table public.todos enable row level security;Do this first, before you write any policies. The table goes dark for the anon and authenticated roles, which is the safe default. Then you open it back up with policies, one operation at a time.
Example RLS policies using auth.uid()
Supabase Auth puts the signed in user's id in the request's JWT, and the auth.uid() function reads it inside a policy. The usual pattern is a user_id column on each row and a policy that compares the two. These four policies give each user full control over their own todos and no access to anyone else's:
-- Rows record who owns them
alter table public.todos
add column user_id uuid not null default auth.uid() references auth.users (id);
alter table public.todos enable row level security;
create policy "Owners can read their todos"
on public.todos for select
to authenticated
using ((select auth.uid()) = user_id);
create policy "Owners can create their own todos"
on public.todos for insert
to authenticated
with check ((select auth.uid()) = user_id);
create policy "Owners can update their todos"
on public.todos for update
to authenticated
using ((select auth.uid()) = user_id)
with check ((select auth.uid()) = user_id);
create policy "Owners can delete their todos"
on public.todos for delete
to authenticated
using ((select auth.uid()) = user_id);- using decides which existing rows a user can see or touch. It applies to select, update and delete.
- with check decides which new or changed rows are allowed. It applies to insert and update. Without it on update, a user could edit their own row and set user_id to somebody else's id.
- to authenticated limits the policy to signed in users. The anon role gets nothing from these policies.
- Wrapping auth.uid() in (select ...) lets Postgres evaluate it once per query instead of once per row. Supabase recommends it for performance, and it behaves the same.
If some rows really are public, such as published blog posts, write a separate select policy for that case, for example to anon, authenticated using (published = true). Keep writes owner only.
Common Supabase RLS mistakes that leave data open
- RLS never enabled. The table works perfectly in the app, so nobody notices it is also readable by the whole internet. Check this one first.
- Policies with using (true). A policy like "Enable read access for all users" with using (true) opens the table to every role the policy names. Written for anon, the table is public. Written for authenticated, the table is readable by anyone who signs up, and on an app with open sign up that means anyone.
- Trusting user_metadata. Users can edit their own raw user metadata from the client, so a policy that checks auth.jwt() -> 'user_metadata' for a role or plan can be bypassed. Keep roles in a table users cannot write to, or in app_metadata, which only the server can set.
- Forgetting Storage. File access is controlled separately, by policies on the storage.objects table and by the bucket's public flag. A public bucket serves any file to anyone who has or guesses the URL, whatever your table policies say. Keep private uploads in private buckets.
- Views that skip RLS. A Postgres view runs with its owner's permissions by default, and the owner is usually a role that bypasses RLS. A view over a protected table can expose every row. On Postgres 15 and later, create views with security_invoker = true so the caller's policies apply.
- Security definer functions. A function marked security definer also runs as its owner, and functions in the public schema can be called through /rest/v1/rpc/. Check what each one returns and who can call it.
- Using the service role key in the browser. The service_role key and the newer sb_secret_ keys bypass RLS entirely. If one ends up in your front end, your policies stop mattering. Those keys belong only in server code, such as Edge Functions or your own API routes.
create view public.todo_counts
with (security_invoker = true)
as select user_id, count(*) from public.todos group by user_id;
-- or fix an existing view
alter view public.todo_counts set (security_invoker = true);create policy "Users read their own files"
on storage.objects for select
to authenticated
using (
bucket_id = 'uploads'
and (storage.foldername(name))[1] = (select auth.uid())::text
);How to test your RLS policies yourself
Start with the Security Advisor in the Supabase dashboard (under Advisors). It lints your database and flags tables in the public schema with RLS disabled, views defined with security definer behavior, and tables that have policies while RLS is off. Fix everything it marks as an error.
Then check from the outside, the way a stranger would. Take your project URL and the anon or publishable key from your own front end and ask for a table directly. Only do this against your own project.
curl "https://YOUR_PROJECT_REF.supabase.co/rest/v1/todos?select=*&limit=5" \
-H "apikey: YOUR_ANON_OR_PUBLISHABLE_KEY"- [] means the anon role can see no rows. That is what you want for private tables.
- An error such as permission denied also means the table is closed.
- Real rows mean anyone on the internet can read that table. Enable RLS or tighten the policy, unless the data is meant to be public.
Repeat the request for every table your app uses. Then test as a signed in user: create two test accounts, sign in as the first, and try to fetch, edit and delete a row that belongs to the second. Each attempt should return nothing or fail.
A prompt to give your AI agent
If an agent built your app, have it do the audit, then check its work with the Security Advisor and the curl test above.
Audit this project's Supabase security.
1. List every table in the public schema and say whether row level security is enabled. Write a migration that enables RLS on every table that lacks it.
2. For each table, write explicit policies for select, insert, update and delete using (select auth.uid()) compared to the owning user_id column. Use "with check" on insert and update. Do not use "using (true)" unless the data is meant to be public, and tell me which tables you treated as public.
3. Find any views and recreate them with security_invoker = true. List any security definer functions and what they return.
4. Check storage buckets: private user files must be in private buckets with policies on storage.objects scoped to the user's folder.
5. Confirm the service_role or sb_secret_ key is only used in server code and never in any file that ships to the browser.
Show me the SQL before running it.What an outside scan can and cannot check
Our scan looks at your live site from the outside, with ordinary page requests. It cannot test RLS, because that would mean querying your database, and it never does. What it can catch is the outside half of the problem: a Supabase service_role or sb_secret_ key leaked into your page or JavaScript bundle (the anon key is correctly treated as public), an exposed .env file or .git folder, and missing security headers. The RLS half is on you, and the Security Advisor plus the curl test above cover it.
Questions
What is RLS in Supabase?
It is Postgres row level security, which Supabase uses as the access control for its auto-generated API. Policies attached to each table decide which rows each user can read, insert, update or delete.
Is the Supabase anon key safe to put in my front end?
Yes, it is designed to be public, but only because RLS is supposed to be on. With RLS off on a table, the anon key gives anyone full access to that table.
Does the service role key respect RLS?
No. The service_role key and the newer sb_secret_ keys bypass RLS entirely. Use them only in server code, and rotate them from the project's API key settings if one ever reaches a browser.
My app stopped loading data after I enabled RLS. What happened?
RLS with no policies denies everything to the anon and authenticated roles. Add a select policy (and insert, update and delete policies as needed) that matches who should see each row.
Is using (true) ever fine?
For genuinely public data, such as a list of published articles, a select policy with using (true) is fine. Never use it on insert, update or delete, and never on tables holding user data.