Home / Guides

Supabase Row Level Security (RLS) checklist for vibe-coded apps

Your Supabase anon (publishable) key is public by design, so Row Level Security is what actually protects your data. Here is how to turn it on for every table, check it in the dashboard, write policies, lock down storage and test it with a logged-out request.

Updated 2026-10-19 · ShipCheck guides

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:

3. Check RLS status in the Supabase dashboard

select tablename, rowsecurity
from pg_tables
where schemaname = 'public'
order by tablename;

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 );

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.

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.

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"

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

Official reference: Supabase docs: Row Level Security. If your app was built with Lovable, also see Is my Lovable app secure?

Scan your app free

Paste your live URL or public GitHub repo. ShipCheck checks for leaked keys, committed .env files, missing security headers, HTTPS issues and slow pages, then gives you copy-paste fixes. No signup.

FAQ

Is the Supabase anon key a secret?

No. The anon key (now called the publishable key) is designed to be public and ships in your frontend. It is only safe when Row Level Security is enabled on every table in an exposed schema, because anyone holding it can call your database API directly.

What happens if I enable RLS but don't add any policies?

Requests made with the anon or publishable key get no rows back until you add a policy that allows them, so enabling RLS first is the safe default. The service_role and secret keys still bypass RLS, which is why they must stay on the server.

Does ShipCheck test my Supabase RLS policies?

No. ShipCheck is a passive outside-in scan. It flags leaked Supabase service_role keys and other secrets in your HTML, JavaScript bundle and repo, plus exposed .env files, but it does not log in to your database or test RLS. Use the logged-out curl test in this guide for that.

More guides

Is my Lovable app secure?

How to check whether your Lovable app is secure: Supabase Row Level Security, exposed keys, security headers, auth and storage. A step-by-step checklist with fixes.

Exposed .env file or API key? How to check and rotate

How to find leaked API keys and committed .env files in your repo, JavaScript bundle or live site, rotate them safely, and stop it happening again.

The vibe-coded app launch checklist

A practical pre-launch checklist for apps built with Lovable, Cursor, Bolt, v0 or Replit: security, secrets, database rules, performance, SEO, legal and monitoring.

How to add security headers on Vercel

Copy-paste vercel.json to add CSP, HSTS, X-Frame-Options, X-Content-Type-Options, Referrer-Policy and Permissions-Policy on Vercel, plus how to test them.