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.
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.
Keep reading
All postsTroja vs. checkvibe: the closest scanner comparison (2026)
checkvibe pioneered security + SEO + AEO scanning with AI fix prompts and a 7-engine matrix. Troja matches it and adds connected deep-stack scans. The honest comparison.
ReadTroja vs. Fixnx: which AI website scanner should you use?
Fixnx runs 100+ AI-powered security, SEO and speed checks with credit-pack pricing. Troja adds AEO, connected deep-stack scans and per-finding AI fixes. Compared.
ReadTroja vs. CyScan.io: recon tool vs. fix-it scanner
CyScan.io is a free attack-surface recon scanner — endpoints, subdomains, fuzzing, screenshots. Troja is a fix-and-ship scanner with AI fixes, AEO and deep-stack scans.
Read