Guide
How to check Supabase row-level security before launch
Tables without RLS, policies that let everyone in, missing with check, roles in user_metadata, views and security definer functions: what goes wrong and the fix for each.
Your Supabase anon key is in your frontend by design. Anyone can copy it and call your database directly, for example supabase.from("profiles").select("*") from the browser console. Row-level security (RLS) is the only thing deciding what that call returns. These are the gaps to check before launch.
Examples assume the standard Supabase SSR setup, where createClient() in lib/supabase/server.ts returns a client that acts as the signed-in user.
1. A table with RLS turned off
-- Bad
create table public.profiles (
id uuid primary key references auth.users (id),
full_name text,
phone text
);
-- Good
create table public.profiles (
id uuid primary key references auth.users (id) on delete cascade,
full_name text,
phone text
);
alter table public.profiles enable row level security;
create policy "Users read own profile" on public.profiles
for select to authenticated using ((select auth.uid()) = id);
create policy "Users update own profile" on public.profiles
for update to authenticated
using ((select auth.uid()) = id)
with check ((select auth.uid()) = id);
Without RLS, every row can be read, changed and deleted with the public key. With RLS on and no policies, nothing gets through, which is a safe starting point.
2. A policy that allows everyone
-- Bad
create policy "Enable insert for all" on public.posts for insert with check (true);
create policy "Enable update for all" on public.posts for update using (true);
-- Good
create policy "Owners insert posts" on public.posts
for insert to authenticated with check ((select auth.uid()) = author_id);
create policy "Owners update posts" on public.posts
for update to authenticated
using ((select auth.uid()) = author_id)
with check ((select auth.uid()) = author_id);
RLS shows as enabled, so the dashboard looks fine, but true lets anyone write anything. Policies like this often appear because they make a permission error go away. A policy that only checks someone is logged in (auth.uid() is not null) has the same problem for tables that belong to users: any account can reach every row.
3. An update policy without with check
-- Bad
create policy "Owners update" on public.posts
for update to authenticated using ((select auth.uid()) = author_id);
The fix is to add with check ((select auth.uid()) = author_id), as in section 2. using checks the row before the change and with check checks it after. Without it, a user can update their own row and set author_id to someone else's ID.
4. Roles read from user_metadata
-- Bad
create policy "Admins read all" on public.orders
for select using ((auth.jwt() -> 'user_metadata' ->> 'role') = 'admin');
-- Good
create policy "Admins read all" on public.orders
for select to authenticated
using ((auth.jwt() -> 'app_metadata' ->> 'role') = 'admin');
Any user can call supabase.auth.updateUser({ data: { role: "admin" } }) and edit their own user_metadata. app_metadata can only be set from the server with the service role. A user_roles table that users can't write to works too.
5. A view that skips RLS
-- Bad
create view public.order_summaries as
select o.id, o.user_id, o.total, p.full_name
from orders o join profiles p on p.id = o.user_id;
-- Good
create view public.order_summaries
with (security_invoker = true) as
select o.id, o.user_id, o.total, p.full_name
from orders o join profiles p on p.id = o.user_id;
By default a view runs with its creator's rights, which skips RLS on the tables underneath, so every user sees every order through it.
6. A security definer function anyone can call
-- Bad
create function public.get_user_email(uid uuid) returns text
language sql security definer as $
select email from auth.users where id = uid;
$;
-- Good
create function public.get_my_email() returns text
language sql security definer set search_path = '' as $
select email from auth.users where id = (select auth.uid());
$;
revoke execute on function public.get_my_email() from anon, public;
grant execute on function public.get_my_email() to authenticated;
Functions in public can be called through the API (supabase.rpc("get_user_email", { uid })), and security definer means they skip RLS. Read the user from auth.uid() rather than taking an ID as an argument.
7. Using the service role to "fix" a permission error
// Bad: "RLS was blocking it, so I switched to the admin client"
const { data } = await supabaseAdmin.from("notes").select("*").eq("id", noteId);
// Good
const supabase = await createClient(); // acts as the signed-in user
const { data } = await supabase.from("notes").select("*").eq("id", noteId);
The error was RLS doing its job. If it blocks a legitimate request, write the missing policy. The admin client returns any user's note to anyone who knows or guesses the ID.
8. Public storage buckets for private files
Make the bucket private, store files under the user's ID (invoices/<user_id>/file.pdf), and add policies:
create policy "Users read own files" on storage.objects
for select to authenticated
using (bucket_id = 'invoices'
and (storage.foldername(name))[1] = (select auth.uid())::text);
create policy "Users upload own files" on storage.objects
for insert to authenticated
with check (bucket_id = 'invoices'
and (storage.foldername(name))[1] = (select auth.uid())::text);
Serve files with createSignedUrl() and a short expiry. Public bucket URLs work for anyone who has them, with no login.
Checking it automatically
Seaworthy reads your supabase/migrations and flags sections 1 and 2 (SW-D01, SW-D02), and section 7 when a route loads a record by ID through the admin client with no owner check (SW-A02). It also flags public buckets whose names suggest private files (section 8, SW-D04). It doesn't check sections 3 to 6 yet. Run it with:
npx seaworthy-cli
These fixes reduce common risks. They are not a security audit or a guarantee.