Where the trust boundary is
Supabase exposes your database over an HTTP API. Requests arrive as one of two Postgres roles: anon for visitors with no session, and authenticated for signed-in users. The publishable key (or the legacy anon key) is designed to sit in your front end, and Supabase states that publishable keys are low privilege and subject to Row Level Security. Your protection is therefore whatever RLS policies, grants and function permissions say.
The secret key (or the legacy service_role key) bypasses RLS entirely. Supabase documents it as backend-only, and says it does not work in a browser. If a secret key ever reaches client code, a repository or a chat, treat the whole database as exposed and rotate it.
For the general concept of why browser code cannot enforce access, see our guides on environment variables and client-side secrets. This page is only about what to check inside Supabase.
The checklist
| Area | What to verify | How to test on your own project | Pass condition |
|---|---|---|---|
| RLS | Every table in an exposed schema has RLS enabled and a policy per operation | Run the RLS query in Step 1, then open the Security Advisor | No table listed with rls off; no policy that reads using (true) for writes |
| Policies | Policies name a role and tie rows to the caller | Read each policy; sign in as user A and request user B's row id | Second user gets zero rows or an error |
| Keys | Only the publishable or anon key is in the bundle | Search the built JS and repository for sb_secret_ and service_role | No secret key anywhere client-side or in git history |
| Storage | Private buckets have policies on storage.objects scoped to the user | Request an object URL logged out; try listing another user's folder | Logged-out and cross-user requests fail |
| Auth settings | Signups, email confirmation, anonymous sign-ins and redirect URLs match your intent | Review Authentication settings and URL configuration in the dashboard | Only intended sign-in paths are on; redirect list has no wildcards you do not own |
| Edge functions | JWT verification is on unless the function is a webhook with its own check | Call the function with no Authorization header | Private functions reject the call |
| Views and functions | Views use security_invoker; security definer functions are not callable by anon | Run the advisor and the view query in Step 5 | No security definer views; no exposed auth.users data |
Step 1: Find tables without RLS
Open the SQL editor and list tables in the public schema with their RLS flag. Any row where rowsecurity is false is readable and writable by anyone who has your project URL and public key, subject to grants.
select schemaname, tablename, rowsecurity
from pg_tables
where schemaname = 'public'
order by rowsecurity, tablename;Step 2: Read and write policies
Enabling RLS with no policies denies everything, which is safe but breaks the app, and generated code often fixes that by adding a policy with using (true). Supabase recommends separate policies for select, insert, update and delete, and naming the role with the to clause. A policy that is true for anon on a table of user data is a public table.
Write ownership policies against the signed-in user. Wrapping auth.uid() in a select lets Postgres evaluate it once per query, which the Supabase advisor also recommends.
alter table public.notes enable row level security;
create policy "read own notes" on public.notes
for select to authenticated
using ((select auth.uid()) = user_id);
create policy "insert own notes" on public.notes
for insert to authenticated
with check ((select auth.uid()) = user_id);Step 3: Prove it from outside
Use the same request a stranger would. Substitute your project reference and public key, and run it against a table that should be private. You should get an empty array or an error, never rows.
curl -s "https://PROJECT_REF.supabase.co/rest/v1/notes?select=*&limit=5" \
-H "apikey: $SUPABASE_PUBLISHABLE_KEY" \
-H "Authorization: Bearer $SUPABASE_PUBLISHABLE_KEY"Step 4: Storage and keys
Supabase Storage does not permit uploads to a bucket without an RLS policy on storage.objects. Public buckets bypass RLS, so anything in one is fetchable by URL. Put user documents in private buckets and scope policies to a folder named after the user.
For keys, search your repository and its history for secret-key prefixes, and check the deployed bundle. If a secret key was exposed, Supabase's documented rotation is: create a replacement, update every component that used it, confirm nothing still uses the old one, then delete it.
create policy "users read own files" on storage.objects
for select to authenticated
using (
bucket_id = 'documents'
and (storage.foldername(name))[1] = (select auth.jwt()->>'sub')
);
git log -p --all -S"sb_secret_" | head -50
grep -rnE "sb_secret_|service_role" src distStep 5: Views, functions and the advisor
A view runs with its owner's rights by default, which can bypass RLS on the tables underneath. On Postgres 15 and later, create views with security_invoker so the caller's policies apply. Functions marked security definer also bypass RLS, so revoke execute from anon and authenticated unless you mean to expose them.
Supabase's Security Advisor checks all of this. It reports RLS disabled in public, security definer views, exposed auth users and sensitive columns without RLS. Run it after every schema change.
create view public.my_notes
with (security_invoker = true)
as select id, title from public.notes;
supabase db advisorsStep 6: Auth settings and edge functions
In the dashboard, confirm whether new signups are allowed, whether email confirmation is required, and whether anonymous sign-ins are on. If you are building an internal tool, disable signups. Review the redirect URL allow list so sign-in links cannot be sent to a domain you do not control.
Edge functions verify the caller's JWT by default. Setting verify_jwt to false is legitimate for webhooks that authenticate another way, such as a signature, but it must be a deliberate choice. Inside a function, use the client scoped to the user so RLS still applies, and use the admin client only after your own authorization check.
curl -i -X POST "https://PROJECT_REF.supabase.co/functions/v1/my-function" \
-H "Content-Type: application/json" \
-d '{}'Common mistakes
- Adding using (true) to make an error go away.
- Putting the secret key in a front-end environment variable because a tutorial did.
- Enabling RLS on the table but leaving a view over it without security_invoker.
- Trusting a user id sent in the request body instead of auth.uid().
- Marking a bucket public for convenience during development and never reverting.
- Testing only as the owner account, so cross-user leaks never show up.
Keep it working
Re-run the RLS query, the advisor and the logged-out request after every migration or AI-generated schema change. Create two test accounts and keep them for regression checks.
Frequently asked questions
Is it a problem that my Supabase URL and public key are visible in the browser?
No. They are meant to be public. What matters is that RLS is enabled with real policies on every exposed table, because the public key only gets the access your policies allow.
Does enabling RLS break my app?
It denies all access until policies exist, so expect errors at first. Add narrow policies for each operation and role, then test as a normal user. Do not respond by adding a policy that allows everyone.
When is the secret or service_role key acceptable?
Only on servers you control, such as edge functions or backend jobs that do their own authorization checks. Never in a browser bundle, mobile app or public repository.
Should I turn off signups?
If the app is for a fixed set of people, yes. Open signups plus permissive policies means strangers can create accounts and read what authenticated users can read.