Supabase security
Supabase RLS checklist: 12 checks before you launch
Your publishable key ships in your frontend, so the only thing between a stranger and your users' data is Row Level Security. Here's what we check first on every project we review, with the SQL to find and fix each problem.
1RLS is on for every table in an exposed schema
Start here, because nothing else matters if this one fails. Supabase's own docs put it bluntly: until you enable row level security and write a policy, anyone holding your project's publishable key can read and write every row. Tables you create in the Dashboard get RLS automatically. Tables created in the SQL Editor or by a migration don't.
This query lists every table in public that's still wide open:
select schemaname, tablename
from pg_tables
where schemaname = 'public' and rowsecurity = false;
alter table public.your_table enable row level security;2Nobody added USING (true) to make an error go away
RLS with no policies blocks everything, which is the right default. The trouble starts when a query fails during development and someone adds a permissive policy to unblock it. USING (true) on a SELECT policy turns the table public again, just with extra steps.
select tablename, policyname, cmd, qual, with_check
from pg_policies
where schemaname = 'public'
and (qual = 'true' or with_check = 'true');3Write policies have a WITH CHECK
USING decides which rows a user can see or target. WITH CHECK decides what they're allowed to write. Leave it off an INSERT or UPDATE policy and a signed-in user can create or move rows into someone else's account.
create policy "Users update their own profile"
on public.profiles for update
to authenticated
using ((select auth.uid()) = id)
with check ((select auth.uid()) = id);4Policies say who they're for
A policy without TO applies to every role, anon included. If it's meant for signed-in users, write TO authenticated. It's clearer to the next person reading it, and Postgres can skip the policy entirely for other roles.
5No policy trusts user_metadata
Users can change their own raw_user_meta_data through auth.updateUser(). So a policy that checks a role or plan stored there is a policy the user can edit. Keep anything that grants access in app_metadata, which only your server can write, or in a table of your own.
6Views aren't quietly skipping your policies
This one surprises people. Views bypass RLS by default, because they're usually created by the postgres user and run with its rights. On Postgres 15 and later you can make a view respect the caller's policies with security_invoker:
create view public.my_view
with (security_invoker = true)
as select ...;7No SECURITY DEFINER functions in an exposed schema
Anything in public is callable at /rest/v1/rpc/. A SECURITY DEFINER function runs with its owner's rights and ignores RLS, so in an exposed schema it's an open door. Supabase's docs say never to create one there. Use the default, security invoker, wherever you can.
If you really need definer rights, put the function in a schema the API doesn't expose, pin search_path to empty, and lock down who can execute it:
create schema if not exists private;
create or replace function private.is_team_member(team uuid)
returns boolean
language sql
security definer
set search_path = ''
as $
select exists (
select 1 from public.memberships
where team_id = team and user_id = (select auth.uid())
);
$;
revoke execute on function private.is_team_member(uuid) from public, anon;8Public buckets are public on purpose
Anyone with the URL can download a file from a public bucket. That's fine for product images and not fine for invoices. Private buckets are controlled by policies on storage.objects, so check each one restricts access to the owner's folder or the right team.
create policy "Users read their own files"
on storage.objects for select
to authenticated
using (
bucket_id = 'documents'
and (storage.foldername(name))[1] = (select auth.uid())::text
);9Your secret key isn't in the browser
The secret key (sb_secret_..., or the legacy service_role key) bypasses every policy you've written. Search your production bundle for it, and check your environment variable names: anything starting with NEXT_PUBLIC_, VITE_ or EXPO_PUBLIC_ gets shipped to the client.
If it has leaked, fix whatever leaked it first, then create a new secret key, swap it in everywhere, and only then delete the old one. The publishable key (or legacy anon key) is the one meant for the browser.
10Policies are fast enough that nobody wants to remove them
Slow policies get deleted. Wrap auth.uid() in a subselect so Postgres evaluates it once per query instead of once per row, and index the columns your policies filter on.
-- evaluated once, not per row
using ((select auth.uid()) = user_id)
create index on public.documents (user_id);11Realtime tables have SELECT policies
Subscriptions to database changes respect RLS for whoever is listening. Any table you add to the supabase_realtime publication needs a SELECT policy that matches who should receive its changes.
12You actually tried to break it
Policies look correct right up until someone tests them. Query your API with only the publishable key. Then sign in as a second test user and try to read and change the first user's rows. Then open Advisors in the Supabase dashboard: the security checks flag tables with RLS disabled, RLS enabled with no policies, and views that bypass RLS.
curl 'https://YOUR_PROJECT.supabase.co/rest/v1/profiles?select=*' \
-H "apikey: YOUR_PUBLISHABLE_KEY"