Supabase Security Checklist for Shipping Safe

A pre-launch Supabase security checklist: RLS, owner policies, exposed keys, storage rules and the settings that quietly leak data if you skip them.

By Artem Vysotskyi · Updated

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.

  1. Enable RLS on every public table
  2. Write owner-scoped policies
  3. Never ship the service_role key
  4. Lock down Storage buckets
  5. Review anon role grants
  6. Protect internal tables from the data API
  7. Check Edge Function auth
  8. Rotate any leaked keys
  9. 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

FAQ

Is RLS on by default in Supabase?

No. New tables are created with RLS off. Until you enable it, any table in the public schema is readable and writable through the data API by anyone with your anon key, which is public by design. Enabling RLS on every table should be the first item on your pre-launch checklist. #

Is the anon public key safe to expose?

Yes, by itself. The anon key is meant to ship in your client. It is only as safe as your RLS policies, though: with RLS off or with permissive policies, the anon key reads everything. Treat the key as public and the policies as the actual security boundary. #

What is the difference between anon and service_role?

The anon key represents an unauthenticated visitor and is subject to RLS. The service_role key bypasses RLS on every table and is meant for trusted server-side code only. Exposing the anon key is normal. Exposing the service_role key is a full database compromise. #

How do I test my RLS policies?

Test from outside your app. Call the REST API with only the anon key and confirm you cannot read other users' rows. Then sign in as two different test users and confirm each sees only their own data. Supabase's SQL editor can also impersonate roles with `set role anon;` for quick checks.

Find out in 30 seconds

Paste your URL and LeakRank shows what a stranger can actually read from your app. Free, no signup.

Scan my app free →

Related guides