Troja
All posts
SupabaseMar 22, 2026·17 min read

Supabase Security Checklist: 15 Things to Check Before Launch

Supabase exposes your Postgres database to the browser. That's powerful — and dangerous if RLS is off. Here are 15 concrete checks, with real policies, before you go live.

By Security Desk

The Supabase trade-off

Supabase hands the browser a direct line to your Postgres database using the public anon key. That's the whole point — and the whole risk. Without Row Level Security (RLS), anyone with your anon key (which ships in the client bundle) can read and write your tables. Here are 15 checks before launch.

1. RLS is enabled on every table

This is the big one. A table without RLS is fully readable/writable via the anon key.

alter table public.profiles enable row level security;
alter table public.orders   enable row level security;

Verify there are no exceptions:

select tablename, rowsecurity
from pg_tables
where schemaname = 'public' and rowsecurity = false;

That query should return zero rows for any table holding real data.

2. RLS is enabled AND policies exist

RLS with no policies denies everything (safe but broken). RLS off allows everything (dangerous). You want RLS on with correct policies.

create policy "users read own profile"
on public.profiles for select
using (auth.uid() = user_id);

3. Separate policies per operation

Don't use one for all policy. Be explicit about select/insert/update/delete:

create policy "insert own row" on public.orders
for insert with check (auth.uid() = user_id);

create policy "update own row" on public.orders
for update using (auth.uid() = user_id)
with check (auth.uid() = user_id);

using filters which rows are visible; with check validates rows being written. You usually need both.

4. The service_role key is server-only

The service_role key bypasses RLS entirely. If it ever reaches the browser, your database is wide open. It belongs only in server environments:

grep -rn "service_role\|SERVICE_ROLE" ./app ./components ./src

Any hit in client-side code is a critical finding.

5. Only the anon key is public

Confirm your client uses NEXT_PUBLIC_SUPABASE_ANON_KEY — never the service key. The anon key is meant to be public; it's safe only because RLS is on.

6. Storage buckets have policies

Storage is governed by its own RLS-style policies. A public bucket is readable by the entire internet.

create policy "users read own files"
on storage.objects for select
using (bucket_id = 'avatars' and auth.uid()::text = (storage.foldername(name))[1]);

7. No privilege fields the user can write

Never let a user update their own role or is_admin. Restrict which columns the policy permits, or enforce it with a trigger / separate admin path.

8. Database functions use the right security context

security definer functions run with the creator's privileges and bypass the caller's RLS. Use them deliberately, set a safe search_path, and prefer security invoker unless you specifically need elevation:

create function public.get_my_orders()
returns setof orders
language sql
security invoker
set search_path = public
as $$ select * from orders where user_id = auth.uid(); $$;

9. The anon role can't reach sensitive schemas

Lock down direct grants. The anon and authenticated roles should only touch what they need.

10. Email confirmations are on

In Auth settings, require email confirmation so attackers can't sign up as arbitrary addresses and inherit access.

11. Strong password and rate-limit settings

Set a minimum password length and enable Supabase's built-in auth rate limiting to blunt brute-force and enumeration.

12. Realtime is scoped by RLS

Realtime subscriptions respect RLS only if it's enabled. A table broadcasting changes without RLS leaks every row to every subscriber.

13. No secrets in Edge Functions logs

Edge Functions are great, but console.log of a token or key ends up in logs. Scrub them.

14. CORS and allowed redirect URLs are tight

In Auth → URL Configuration, list only your real domains as redirect URLs. A wildcard here enables token-stealing redirects.

15. Test as an unauthenticated user

The real proof. Use the anon key with no session and try to read a protected table:

const supabase = createClient(url, ANON_KEY);
const { data, error } = await supabase.from("orders").select("*");
// data should be [] (or error) — NOT every order in the table

If you get rows back, RLS is misconfigured. Fix it before launch.

Checklist

  • RLS enabled on every table
  • Correct per-operation policies (using + with check)
  • service_role key server-only
  • Storage bucket policies set
  • No client-writable privilege fields
  • security definer functions audited
  • Realtime tables RLS-protected
  • Redirect URLs and CORS locked down
  • Verified as an anonymous user

Scan it with Troja

Troja tests your Supabase project the way an attacker would — using your public anon key to probe which tables and buckets actually return data — and flags every table where RLS isn't doing its job. Scan it and see exactly what's reachable.

Run the scan this post is about.

Free, no signup. See what's hiding inside your walls in ~30 seconds.

Free scan · no signup · results in ~30 seconds
Supabase Security Checklist: 15 Things to Check Before Launch — Troja