Guides · 9 min read
Supabase Row Level Security explained (with copy-paste policies)
If your app runs on Supabase — including most apps built with Lovable or Bolt — one setting decides whether your users’ data is private or public: Row Level Security (RLS). This guide explains what it does, gives you the policies most apps need, and lists the mistakes I find most often, in my own apps and in the code I review.
Why RLS matters more on Supabase than anywhere else
Supabase gives every table in your exposed schemas (by default, public) an instant REST API. Your front-end calls that API with the publishable key (the legacy anon key on older projects). That key is in your JavaScript, so anyone can find it — and that’s by design.
What stops a stranger from using that key to read your whole database is RLS. With RLS enabled, Postgres checks a set of rules (policies) on every request. Without it, any role with access to the table can read and write every row.
The other key, the secret key (legacy: service_role), bypasses RLS entirely. It must never reach the browser. Supabase is retiring the legacy anon and service_role keys by the end of 2026, so if your code still uses them, plan the switch.
Step 1: turn RLS on, and set the grants
Tables created from the Supabase dashboard have RLS enabled by default. Tables created with SQL or migrations — which is what AI tools usually generate — don’t, unless the script enables it. Put these lines in the same migration that creates the table:
alter table public.notes enable row level security;
-- Policies don't remove table privileges: revoke, then grant back only what the app needs
revoke all on table public.notes from anon, authenticated;
grant select, insert, update, delete on table public.notes to authenticated;Once RLS is on and there is no policy, nobody can reach the table through the API with the publishable key. That’s the safe default: you then open exactly what you need. The grants are the second lock: signed-out visitors here get nothing at all.
Let Supabase lock new tables for you
Supabase now offers two safety nets. Turn on both, especially if an AI tool writes your migrations:
- New tables not exposed by default. Since May 30, 2026, new projects no longer expose new tables in
publicto the Data API until you grant access explicitly. On older projects it’s a choice at creation (“Default privileges for new entities”), or a fewrevokestatements to run yourself. - RLS enabled automatically. An event trigger can switch RLS on for every table created in
public, however it was created. The dashboard can install it for you; the Supabase hardening guide has the SQL.
Neither writes your policies. They only make the default “closed” instead of “open”, which is exactly what you want when you add a table in a hurry and forget about it. Existing tables aren’t changed: check those with the control query below.
Step 2: the four policies most tables need
For a table where each row belongs to one user (notes, orders, settings…), with a user_id column:
-- Read your own rows
create policy "read own notes" on public.notes
for select to authenticated
using ((select auth.uid()) = user_id);
-- Create rows only for yourself
create policy "insert own notes" on public.notes
for insert to authenticated
with check ((select auth.uid()) = user_id);
-- Edit your own rows, and keep them yours
create policy "update own notes" on public.notes
for update to authenticated
using ((select auth.uid()) = user_id)
with check ((select auth.uid()) = user_id);
-- Delete your own rows
create policy "delete own notes" on public.notes
for delete to authenticated
using ((select auth.uid()) = user_id);using decides which existing rows a user can see or touch. with check decides what a new or updated row is allowed to look like. Wrapping auth.uid() in a select lets Postgres evaluate it once per query instead of once per row, which keeps large tables fast. Add an index on user_id for the same reason.
The five mistakes that leak data
1. The table you added “just to test”
This is the one I’ve made myself, many times. You’re building a feature, you add a table to see if it works, and RLS is the last thing on your mind. The feature works, you move on to the next one, and the table stays open.
I keep a checklist I run before every release, and that’s usually where I find my forgotten tables. But I have also pushed an unprotected table to production without knowing it. AI coding tools are better at this than they were, but it still happens. The fix: run the control query at the end of this guide after every migration, not once a month.
2. Policies that only check that someone is logged in
using (auth.role() = 'authenticated') or using (true) looks like protection, but it lets every account read every row. Anyone can create an account. Policies must compare the row to the user.
3. Views that ignore your policies
A view runs with the rights of the user who created it, which usually bypasses RLS. On Postgres 15 and later, create views with with (security_invoker = true) so they respect the caller’s policies. On older versions, keep views out of exposed schemas.
4. Security definer functions exposed through the API
Functions marked security definer run with their owner’s rights. If they live in an exposed schema, they can be called from the browser with those rights. Keep them in a private schema, revoke execute from roles that don’t need it, and set search_path = '' on each one so a caller can’t swap in their own objects.
5. The secret key in front-end code
It often starts as a quick fix for a “permission denied” error, and it removes every protection above. It has happened to me twice. An AI coding assistant once pushed one of my apps with the service key in plain text. On another project, Bolt left my Stripe secret keys in the front-end code — and Stripe closed that account. Luckily it was a secondary account and my main one survived.
Supabase’s new secret keys are refused when called from a browser, but the legacy service_role key isn’t, and both work from any script. If a secret ever reached your front-end or your Git history: move the call to a server route or an edge function, create a new key, and delete the old one.
Don’t forget storage
File access in Supabase Storage is controlled by policies on storage.objects. A public bucket serves every file to anyone with the URL. Keep private files in private buckets, with policies that check the owner — for example, a folder named after the user’s ID.
The control query
Add this to your own pre-release checklist. Run it in the SQL editor: it lists every table in the public schema that has RLS disabled. The expected result is no rows.
select c.relname as table_without_rls
from pg_class c
join pg_namespace n on n.oid = c.relnamespace
where n.nspname = 'public'
and c.relkind = 'r'
and not c.relrowsecurity;The Security Advisor in your Supabase dashboard flags the same problem, plus a few others. Read each finding before you dismiss it.
Check your app
RLS being on is only half the answer: the policies have to match what your app actually does. Our free scan spots a secret key leaked in your live JavaScript in seconds, and the Supabase security check reviews every table and policy in your code, with a fix prompt for each finding.
