A safe Supabase app needs Row Level Security enabled on every public table, owner-scoped policies, the service_role key kept strictly server-side, locked Storage buckets and reviewed anon grants. This guide is the full pre-launch checklist, with copy-paste SQL for each step, so you can verify all of it before strangers do.
The Supabase security checklist at a glance
Run through these nine checks before launch. Each one takes a few minutes, and each one closes a hole that real attackers, and curious users with devtools open, actually exploit.
- Enable RLS on every public table
- Write owner-scoped policies
- Never ship the service_role key
- Lock down Storage buckets
- Review anon role grants
- Protect internal tables from the data API
- Check Edge Function auth
- Rotate any leaked keys
- Verify from outside
The rest of the guide walks each step with SQL you can paste into the Supabase SQL editor.
1. Enable RLS on every public table
RLS is off on new tables. Any table in the public schema without RLS is fully readable and writable by anyone holding your anon key, and your anon key ships to every visitor's browser by design.
List the tables that are still open:
select tablename
from pg_tables
where schemaname = 'public'
and rowsecurity = false;
Then enable RLS on each one:
alter table public.<t> enable row level security;
Do this even for tables you think are harmless. A "harmless" profiles table with emails in it is a data leak.
2. Write owner-scoped policies
Enabling RLS with no policies blocks everything, which is safe but breaks your app. The standard pattern is owner-scoped access: a row belongs to the user whose id is in its user_id column, and only that user can touch it.
create policy "Users can read own rows"
on public.todos
for select
using (auth.uid() = user_id);
create policy "Users can insert own rows"
on public.todos
for insert
with check (auth.uid() = user_id);
create policy "Users can update own rows"
on public.todos
for update
using (auth.uid() = user_id);
Write separate policies per operation (select, insert, update, delete) rather than one for all policy. It forces you to think about each path, and most leaks come from the paths nobody thought about.
3. Never ship the service_role key
Supabase gives you two keys. The anon key is designed to be public and is constrained by RLS. The service_role key bypasses RLS entirely, on every table.
The service_role key must only ever live server-side: in server environment variables, Edge Functions or your backend. It must never appear in client code, in a NEXT_PUBLIC_ variable, in a committed .env file or in your JS bundle.
Quick self-audit: search your repo and your built client bundle for service_role and for the key prefix. If it appears anywhere the browser can reach, treat it as leaked and go to step 8.
4. Lock down Storage buckets
Storage has its own policies, separate from your table policies. A public bucket means every file in it is readable by URL, no auth needed.
Check which buckets are public in Dashboard under Storage, or:
select id, name, public from storage.buckets;
Keep buckets private unless the files are genuinely public assets. For private buckets, add owner-scoped Storage policies, for example allowing users to read only files under their own folder:
create policy "Users read own files"
on storage.objects
for select
using (
bucket_id = 'avatars'
and auth.uid()::text = (storage.foldername(name))[1]
);
Serve private files through signed URLs with a short expiry, not through a public bucket.
5. Review anon role grants
RLS controls which rows are visible. Grants control which operations a role may attempt at all. Some tables should not be reachable by the anon role in any form, regardless of policies:
revoke all on <t> from anon;
Do the same with authenticated for tables only your backend should touch. Defense in depth: if a policy is ever written wrong, the missing grant still stops the query.
6. Protect internal tables from the data API
Everything in the public schema is exposed through PostgREST at /rest/v1/. Internal tables, such as job queues, logs, feature flags or billing state, do not belong there.
Move them to a separate schema that is not exposed by the API:
create schema internal;
alter table public.billing_events set schema internal;
Only schemas listed in your project's API settings (by default just public) are served by the data API. Anything in internal becomes unreachable from the client, no policy needed.
7. Check Edge Function auth
Edge Functions verify a JWT by default, but it is easy to switch off with --no-verify-jwt and forget. An unauthenticated function that uses the service_role key internally is a public endpoint with admin powers.
For every function, confirm: JWT verification is on unless the function is intentionally public, the function checks the caller's identity before touching user data, and webhooks validate the provider's signature instead of trusting the payload.
8. Rotate any leaked keys
If the service_role key has ever been in client code, a public repo or a chat paste, rotate it. You cannot un-leak a key, and you have no way to know who copied it.
In Dashboard: Settings, then API, then rotate the JWT secret and regenerate keys. Update your server environment variables and redeploy. While you are there, check Auth logs and database logs for access you cannot explain.
9. Verify from outside
Config review tells you what you intended. Only an outside request tells you what is actually exposed. Test as a stranger, with a plain HTTP client and your public anon key:
curl "https://<project>.supabase.co/rest/v1/todos?select=*" \
-H "apikey: <anon-key>" \
-H "Authorization: Bearer <anon-key>"
You should get an empty array or a 401/403, never other users' rows. Repeat for each table and for Storage URLs. If any request returns data it should not, go back to the matching step above.
Check your whole app in 30 seconds with a free LeakRank scan