Most apps built with Lovable, Bolt, v0 or Cursor on top of Supabase talk to the database straight from the browser. That works well, and it is how Supabase is designed to be used, but it means one setting decides whether your users' data is private: Row Level Security (RLS). This checklist walks through what to turn on, how to check it in the dashboard, which policies to write, and how to prove it works with a logged-out request.
1. Understand why your anon key is public
Your frontend ships with two values: the project URL and the anon key (in newer projects, the publishable key, which starts with sb_publishable_). Anyone can read them from your JavaScript bundle or the browser's network tab. That is expected. Supabase documents these keys as safe to expose, because they only reach what RLS allows.
Every request is mapped to a Postgres role. A request with the publishable or anon key and no signed-in user runs as anon. The same key with a signed-in user's session runs as authenticated. Your RLS policies are written for those two roles. So the question is never "is my anon key hidden?" It is "what can the anon and authenticated roles do in my database?"
Supabase is replacing the legacy JWT-based anon and service_role keys with publishable (sb_publishable_…) and secret (sb_secret_…) keys. The rules in this checklist are the same for both.
2. Turn RLS on for every table in an exposed schema
Supabase's own docs are blunt about this: a table in an exposed schema (usually public) without RLS is readable and writable by any role with a grant on it. In practice that means anyone with your public key can read or change every row through the auto-generated API.
alter table public.todos enable row level security;
Once RLS is on and there are no policies yet, requests made with the publishable or anon key get no rows back. Access is denied until you write a policy that allows it, so turning RLS on is the safe default and you then open up only what the app needs.
Two related gotchas from the Supabase docs:
- Grants still matter. On many projects, new tables in
publicautomatically grantselect,insert,updateanddeletetoanonandauthenticated. Adding policies does not take those grants back. Revoke what a role should never do, for examplerevoke all on table public.reports from anon;. - Views bypass RLS by default. A view created by the
postgresuser returns every row of the underlying table. On Postgres 15 and later, create viewswith (security_invoker = true)so they respect the table's policies, or keep them out of exposed schemas.
3. Check RLS status in the Supabase dashboard
- Security Advisor: open your project, then Advisors → Security Advisor. It flags tables in the public schema without RLS, and other common problems with the built-in roles. Treat every error there as a launch blocker until you have read it.
- SQL Editor: run this query. Any row where
rowsecurityisfalseis a table without RLS.
select tablename, rowsecurity
from pg_tables
where schemaname = 'public'
order by tablename;
- Read the policies themselves. RLS being "on" is not enough. A policy like
using (true)on private data allows everyone, and a policy without atoclause applies to every role.
4. Write policies that scope rows to the signed-in user
For user-owned data, the standard pattern compares the row's owner column with auth.uid(), the ID of the user making the request. Write one policy per operation and name the role with to authenticated:
create policy "Users read own todos"
on public.todos for select
to authenticated
using ( (select auth.uid()) = user_id );
create policy "Users create own todos"
on public.todos for insert
to authenticated
with check ( (select auth.uid()) = user_id );
create policy "Users update own todos"
on public.todos for update
to authenticated
using ( (select auth.uid()) = user_id )
with check ( (select auth.uid()) = user_id );
create policy "Users delete own todos"
on public.todos for delete
to authenticated
using ( (select auth.uid()) = user_id );
usingdecides which existing rows a user can see or change.with checkdecides what a new or updated row may look like, so a user cannot insert a row for someone else or reassignuser_id.- An
updatealso needs a matchingselectpolicy to work as expected. - When nobody is signed in,
auth.uid()returnsnull, so these policies match nothing for logged-out visitors. - Wrapping it as
(select auth.uid())lets Postgres evaluate it once per statement instead of once per row, and an index onuser_idkeeps policies fast as the table grows. - Don't base authorization on
user_metadatain the JWT: signed-in users can edit it. Useapp_metadataor a roles table instead. - For data that really is public, such as published blog posts, a read-only policy with
using (true)is fine. Just make sure it isfor selectonly, and only on tables meant to be public.
5. Lock down storage buckets
Supabase Storage uses the same RLS system, with policies on the storage.objects table. Without policies, Storage does not allow uploads to a bucket.
- Public buckets serve files to anyone who has the URL. Use them only for files meant to be public, like marketing images or public avatars.
- Private buckets are for anything personal: invoices, ID documents, private uploads. Serve those files with signed URLs.
- Scope uploads to a per-user folder. This policy from the Supabase docs only lets a signed-in user upload into a folder named after their own user ID:
create policy "Users upload to own folder"
on storage.objects for insert
to authenticated
with check (
bucket_id = 'my_bucket_id' and
(storage.foldername(name))[1] = (select auth.jwt()->>'sub')
);
Add matching select, update and delete policies for what the app actually does. Upserts (overwriting a file) need select and update as well as insert.
6. Never ship the service_role or secret key to the client
The service_role key and the newer sb_secret_… keys map to the service_role Postgres role, which bypasses RLS completely. Every policy above stops mattering if one of these keys reaches the browser.
- Use them only in code that never reaches a user's device: server routes, Edge Functions, cron jobs.
- Never put them in a browser-exposed variable such as
NEXT_PUBLIC_…,VITE_…,REACT_APP_…orEXPO_PUBLIC_…. Those are inlined into the JavaScript every visitor downloads. - Search your repo and your built JavaScript for
service_role,sb_secret_and long tokens starting witheyJ. - If one leaked, rotate it in Settings → API Keys: create a new secret key, switch every server component to it, then delete the old secret key (or deactivate the legacy keys). Our guide to exposed .env files and API keys covers the rest of the clean-up.
7. Test with a logged-out request
Don't trust the settings screen. Ask the API directly, the way a stranger would, using only your public key:
curl "https://YOUR-PROJECT.supabase.co/rest/v1/todos?select=*" \
-H "apikey: YOUR_PUBLISHABLE_OR_ANON_KEY"
- Good: an empty list
[], or a permission error (code42501) if you revoked the grant. - Bad: real rows. Anyone on the internet can read that table. Fix it before launch.
- If you use a legacy
anonkey and get a 401, add-H "Authorization: Bearer YOUR_ANON_KEY". Don't do this with the new publishable keys: they are not JWTs.
Repeat it for every table, and try a write too (a POST to the same URL with a JSON body). A logged-out write should fail. If it succeeds, delete the test row and fix the policy or grant.
Then test as a real user: sign in as user A, copy a request from the browser's network tab, and change the ID to a row owned by user B. You should get nothing back. For a repeatable check, Supabase supports database tests with pgTAP that you run with supabase test db.
What ShipCheck checks, and what it doesn't
ShipCheck looks at your app from the outside. It flags Supabase service_role keys (the JWT kind) along with other leaked secrets in your page HTML, the JavaScript you ship and your repo files, and it checks for committed or publicly served .env files. It does not log in to your database or test your RLS policies, and its key patterns don't cover the newer sb_secret_ format yet. So run steps 3 and 7 yourself, and search for sb_secret_ by hand.
Quick checklist
- RLS enabled on every table in
public(and any other exposed schema) - Security Advisor shows no errors
- Policies scoped with
auth.uid(), one per operation, withto authenticated - No
using (true)on private data; unneeded grants toanonrevoked - Views use
security_invoker = trueor live outside exposed schemas - Private files in private buckets, with
storage.objectspolicies - No
service_roleorsb_secret_key in client code, browser-exposed env vars or git history - Logged-out
curlreturns no private rows and can't write
Official reference: Supabase docs: Row Level Security. If your app was built with Lovable, also see Is my Lovable app secure?