How to enable Row Level Security in Supabase (with copy-paste policies)
Turn on RLS and add the right policies for the common cases — owner-only rows, public read, profiles, teams, insert-only forms and storage — plus the gotchas that quietly re-open your data.
Updated 2026-10-15
What Row Level Security does
Supabase exposes your public schema through an API that the browser calls with your public anon key. Row Level Security (RLS) is a Postgres feature that filters every query through rules called policies. With RLS on, a request only sees or changes the rows a policy allows. With RLS off, the anon key can read every row.
Three facts worth knowing:
- RLS on + no policies = nobody gets in (through the API). That's a safe default; add policies to open exactly what you need.
- Tables created in the dashboard's Table Editor have RLS on by default, but tables created with raw SQL or migrations (which AI builders often write) only get it if the SQL enables it.
- The
service_role/ secret key bypasses RLS. It belongs on servers only — never in frontend code.
1. Turn RLS on
For one table (replace todos with your table):
alter table public.todos enable row level security;Or for every table in public that doesn't have it yet — review the list it prints first:
do $$
declare t record;
begin
for t in select tablename from pg_tables
where schemaname = 'public' and rowsecurity = false
loop
raise notice 'Enabling RLS on %', t.tablename;
execute format('alter table public.%I enable row level security', t.tablename);
end loop;
end $$;After this, your app may stop showing data until you add policies — that's expected. Add the policies below that match each table.
2. Pick the right policy for each table
All examples use (select auth.uid()) rather than a bare auth.uid(): Postgres then evaluates it once per query instead of once per row, which Supabase recommends for performance. They also name the role with to authenticated so the policy isn't evaluated for anonymous requests at all.
Owner-only rows (todos, notes, orders, messages…)
The table needs a column holding the owner's user id. If it doesn't have one:
alter table public.todos
add column if not exists user_id uuid references auth.users (id) on delete cascade
default auth.uid();
create index if not exists todos_user_id_idx on public.todos (user_id);Then:
create policy "todos: owner can read"
on public.todos for select to authenticated
using ((select auth.uid()) = user_id);
create policy "todos: owner can insert"
on public.todos for insert to authenticated
with check ((select auth.uid()) = user_id);
create policy "todos: owner can update"
on public.todos for update to authenticated
using ((select auth.uid()) = user_id)
with check ((select auth.uid()) = user_id);
create policy "todos: owner can delete"
on public.todos for delete to authenticated
using ((select auth.uid()) = user_id);Note the update policy has both using (which rows you may target) and with check (what the row may look like afterwards) — without the second, a user could reassign their row to someone else's id.
Profiles keyed by the user id
-- profiles.id is the same uuid as auth.users.id
create policy "profiles: read own"
on public.profiles for select to authenticated
using ((select auth.uid()) = id);
create policy "profiles: update own"
on public.profiles for update to authenticated
using ((select auth.uid()) = id)
with check ((select auth.uid()) = id);If other users need to see public bits of a profile (a display name, an avatar), don't open the whole table. Put those fields in a separate public_profiles table with its own read policy, so emails and phone numbers stay private.
Public read, owner write (published posts, listings)
create policy "posts: anyone can read published"
on public.posts for select to anon, authenticated
using (published = true);
create policy "posts: authors can read their drafts"
on public.posts for select to authenticated
using ((select auth.uid()) = author_id);
create policy "posts: authors can write"
on public.posts for insert to authenticated
with check ((select auth.uid()) = author_id);
create policy "posts: authors can update"
on public.posts for update to authenticated
using ((select auth.uid()) = author_id)
with check ((select auth.uid()) = author_id);Teams / organisations
With a memberships (org_id, user_id) table, let members see their organisation's rows:
-- members can see their own memberships (needed by the policy below)
create policy "memberships: read own"
on public.memberships for select to authenticated
using ((select auth.uid()) = user_id);
create policy "projects: org members can read"
on public.projects for select to authenticated
using (
org_id in (
select org_id from public.memberships
where user_id = (select auth.uid())
)
);
create index if not exists memberships_user_id_idx on public.memberships (user_id);Insert-only forms (waitlist, contact, feedback)
Visitors may add a row but never read the list back:
create policy "waitlist: anyone can join"
on public.waitlist for insert to anon, authenticated
with check (true);
-- No select policy: nobody can read the list through the API.
-- Read it in the dashboard or from a server using the secret key.Consider adding a CAPTCHA or rate limit in front of public insert forms to stop spam.
Storage: each user gets a private folder
Keep the bucket private, and store files under <user id>/filename:
create policy "avatars: users read own files"
on storage.objects for select to authenticated
using (
bucket_id = 'avatars'
and (storage.foldername(name))[1] = (select auth.uid())::text
);
create policy "avatars: users upload own files"
on storage.objects for insert to authenticated
with check (
bucket_id = 'avatars'
and (storage.foldername(name))[1] = (select auth.uid())::text
);
create policy "avatars: users delete own files"
on storage.objects for delete to authenticated
using (
bucket_id = 'avatars'
and (storage.foldername(name))[1] = (select auth.uid())::text
);3. Gotchas that quietly re-open your data
using (true)on aselectpolicy foranonorpublicmakes the table world-readable. AI builders sometimes add this to “fix” an empty screen.- “Any logged-in user” isn't private. A policy like
to authenticated using (true)lets every person who signs up read every row. - Views run with their creator's permissions by default and can bypass RLS. On Postgres 15+, create them with
with (security_invoker = true), or keep them out of thepublicschema. security definerfunctions also bypass RLS. Keep them out of exposed schemas, or checkauth.uid()inside them.- Don't authorise with
user_metadata. Users can edit their ownraw_user_meta_data, so a role stored there can be self-assigned. Useapp_metadata(only changeable server-side) or a roles table. - New tables need policies too. Every time your builder adds a table, check it has RLS on.
4. Test it
- Re-run the anonymous test from the leak-check guide: a request with only the anon key should return
[]or a permission error for private tables. - Sign in as two different test users and confirm each sees only their own data. The Supabase SQL editor can also run queries as a specific role or user for quick checks.
- Open Advisors → Security Advisor and confirm there are no RLS errors left.
Prompt for your AI builder
If you'd rather your builder write the migration, paste this and review the SQL before applying it:
Enable Row Level Security on every table in the public schema. For each table,
add policies so signed-in users can only read and change their own rows (match the
owner column against (select auth.uid())). Only allow anonymous read access for
data that is meant to be public, and never use "using (true)" for private data.
Add update policies with both USING and WITH CHECK. Show me the SQL migration
before running it and list which tables (if any) you left publicly readable and why.Sealed — a short weekly security note for vibe coders
One common security mistake in AI-built apps, how to fix it in your builder, and one useful tool. No spam; unsubscribe any time.