Supabase Row Level Security (RLS) for Beginners
Lovable, Bolt and plenty of Cursor projects use Supabase as the database. It’s great, but it has one rule you must understand before launch, and most AI-generated apps get it at least partly wrong.
The one idea that matters
Your app’s browser code contains a Supabase publishable key (older projects call it the anon key). Anyone can read it from your JavaScript, and that’s by design. What actually protects your data is Row Level Security (RLS), a set of rules inside the database.
Supabase’s docs don’t soften it: “A table in an exposed schema without RLS is readable and writable by any role with a grant on it.” In plain English: if a table in your public schema has no RLS, anyone who opens your site can read it and change it. (Supabase: Row Level Security)
Who is asking? The three roles
Every request to Supabase runs as one of these database roles:
| Request comes from | Postgres role | RLS applies? |
|---|---|---|
| Browser, signed out (publishable key) | anon | Yes |
| Browser, signed in (publishable key + user’s login token) | authenticated | Yes |
Your server using the secret key (sb_secret_…, legacy service_role) | service_role | No, it bypasses RLS |
That last row is why the secret key must never be in browser code. (Supabase: API keys)
Grants vs policies: two locks, not one
Postgres checks two things before a request touches a table:
- Grants decide whether a role may run an operation (
select,insert,update,delete) on the table at all. - Policies decide which rows that operation applies to.
On many existing projects, a new table in public automatically grants all four operations to anon and authenticated. Adding policies doesn’t take those grants back, so a table protected only by policies can still give signed-out visitors an insert path. The fix is to set both. (Supabase: Row Level Security)
Pattern 1: “Each user sees and edits only their own rows”
This is what most apps need for things like notes, projects, orders and profiles. The example uses a notes table with a user_id column that stores the owner’s id. Run it in the Supabase SQL Editor, or better, put it in a migration so you can repeat it.
-- 1. Turn on RLS. From now on the publishable key sees nothing
-- until a policy allows it.
alter table public.notes enable row level security;
-- 2. Reset the grants, then give back only what's needed.
revoke all on table public.notes from anon, authenticated;
grant select, insert, update, delete on table public.notes to authenticated;
-- 3. One policy per operation.
create policy "Users can view their own notes"
on public.notes for select
to authenticated
using ( (select auth.uid()) = user_id );
create policy "Users can create their own notes"
on public.notes for insert
to authenticated
with check ( (select auth.uid()) = user_id );
create policy "Users can update their own notes"
on public.notes for update
to authenticated
using ( (select auth.uid()) = user_id ) -- which rows they may touch
with check ( (select auth.uid()) = user_id ); -- what the row may become
create policy "Users can delete their own notes"
on public.notes for delete
to authenticated
using ( (select auth.uid()) = user_id );
-- 4. Keep it fast as the table grows.
create index notes_user_id_idx on public.notes using btree (user_id);
What each part means:
to authenticatedmeans the policy only applies to signed-in users. Always name the role.using (…)filters which existing rows the user can see or touch. Think of it as an invisibleWHEREadded to every query.with check (…)checks what a new or updated row will look like. Without it on update, a user could reassign a row to someone else’suser_id.(select auth.uid())returns the signed-in user’s id. Wrapping it inselectlets Postgres work it out once per query instead of once per row.- Signed-out visitors get nothing.
auth.uid()isnullfor them, and they have no grant anyway.
Pattern 2: “Anyone can read, nobody can write from the browser”
Good for blog posts, announcements or a public product list that your backend fills in.
alter table public.announcements enable row level security;
revoke all on table public.announcements from anon, authenticated;
grant select on table public.announcements to anon, authenticated;
create policy "Anyone can read announcements"
on public.announcements for select
to anon, authenticated
using ( true );
using ( true ) means “every row”. Only use it for data that’s genuinely meant to be public.
Five beginner traps
for allpolicies. Write a separate policy for each operation so it’s clear what each one allows. Supabase recommends this.- Updates that “silently do nothing”. An
updateneeds a matchingselectpolicy to work. - Trusting
user_metadatafor permissions. Users can edit their ownuser_metadata. For roles like “admin”, useapp_metadata(users can’t change it) or a separate roles table. - Views leaking data. Views bypass RLS by default. On Postgres 15+, create them
with (security_invoker = true). - Error
42501. It usually means a missing grant, not a broken policy. Check grants first. A policy that matches no rows doesn’t raise an error at all. It just returns an empty result.
How to check your work without writing code
- Open your live site signed out. In DevTools → Network, find a request to
…supabase.co/rest/v1/…. - Right-click it, choose “Copy as cURL”, and run it in a terminal with a different table name or without the filters.
- Signed out, private tables should return nothing (or a permission error). Signed in as user A, you should never see user B’s rows.
Then open your project’s Security Advisor in the Supabase dashboard and read every finding before you dismiss it. If you’re comfortable with the CLI, Supabase recommends writing pgTAP tests for each table and running them with supabase test db. (Supabase: Row Level Security)
A prompt to give your AI tool
List every table in my Supabase public schema. For each table, tell me:
1) is row level security enabled, 2) which grants anon and authenticated
have, 3) every policy (operation, role, using, with check).
Flag any table without RLS, any "for all" policy, any policy using
user_metadata, and any "using (true)" on private data.
Do NOT change anything yet. Show me the report first.
Read the report before you let it fix anything. If it proposes to disable RLS to make an error go away, say no. That “fix” makes the table public.
Using Lovable Cloud?
Lovable’s built-in backend uses the same RLS concepts. You can review policies under More → Cloud → Database → RLS policies, and the Quick scan that runs when you open the Publish dialog flags tables without RLS and policies that let everyone through. (Lovable: Security overview)
Official sources used: Supabase: Row Level Security · Supabase: API keys · Lovable: Security overview. Checked October 2026.
Want the whole launch path in one place?
The Ship It Kit bundles copy-paste prompts, step-by-step deploy guides and a pre-launch checklist for Lovable, Bolt, Cursor and Replit apps. One-time $19.