Guide · Supabase · Updated
A Supabase RLS checklist for vibe-coded apps
If you built your app with Lovable, Bolt, v0, Replit, or Cursor and it talks to Supabase, this is the page we wanted when we first started looking at how these apps are put together. It is a short list of things to check, in order, with SQL you can paste into the Supabase SQL Editor. You don’t need a security background. You need about an hour and a bit of patience.
The key in your bundle is fine. The missing rules are not.
Open your live app, open the browser’s developer tools, and you will probably find your Supabase project URL and a key sitting right there in the JavaScript. The first time you see it, it feels like you leaked something. You didn’t. That key is called the anon key on older projects and the publishable key (it starts with sb_publishable_) on newer ones, and it is meant to be public. Supabase’s own API keys guide says it is safe to ship in a web page, because it only reaches what Row Level Security allows.
That last part is the whole story. Many AI builders wire the front end straight to Supabase with that public key. When that is how your app works, the only thing deciding who can read or change which rows is Row Level Security (RLS): rules that live inside Postgres, next to your data. If a table has RLS turned off, anyone who copies the key out of your page can read that table, and often write to it too. The Supabase RLS docs put it plainly: a table in an exposed schema without RLS is readable and writable by any role with a grant on it.
The key that must never be public is the other one: the service_role key, or on newer projects a secret key starting with sb_secret_. It skips RLS entirely. It belongs on a server and nowhere else. We come back to it in step 9.
Why this keeps coming up
In 2025 this exact pattern got a CVE number. CVE-2025-48757 describes Lovable-generated sites, through 15 April 2025, whose database tables had missing or insufficient RLS policies, so an unauthenticated stranger could read or write them. The CVE record rates it critical. It also notes that Lovable disputes it, on the grounds that each customer is responsible for protecting their own app’s data.
The researcher who reported it, Matt Palmer, published a write-up of the issue and the mitigations. The fix he describes is the same thing this checklist walks through: turn RLS on for every table, write narrow policies that use auth.uid(), avoid using (true) on anything sensitive, and check your grants.
We are not bringing this up to pick on one builder. The same risk applies to any app whose front end queries Supabase directly, whichever tool wrote it. AI builders are very good at making things work. Whether a stranger can read your users table is a question they don’t always ask for you.
The checklist
Work through these in order. The SQL below uses a notes table with a user_id column as the example. Swap in your own table and column names. Run things on a copy or a branch first if you can, and keep a note of what you changed.
- See which tables are open
- Turn RLS on for every table
- Trim the grants
- Write one policy per operation, tied to auth.uid()
- Be deliberate about what is public
- Don’t base rules on user_metadata
- Check your views
- Check your storage buckets
- Keep the service_role key off the client
01
See which tables are open
Paste this into the SQL Editor. It lists every table in the public schema, whether RLS is on, and what a signed-out visitor holding your public key is allowed to attempt.
-- Every public table: is RLS on, and what may a signed-out visitor try?
select c.relname as table_name,
c.relrowsecurity as rls_enabled,
has_table_privilege('anon', c.oid, 'select') as anon_can_read,
has_table_privilege('anon', c.oid, 'insert,update,delete') as anon_can_write
from pg_class c
join pg_namespace n on n.oid = c.relnamespace
where n.nspname = 'public'
and c.relkind in ('r', 'p')
order by c.relrowsecurity, c.relname;Any row where rls_enabled is false is the first thing to fix. A row where anon_can_write is true deserves a second look even if RLS is on, because a loose policy plus a write grant is still a way in. If you’d rather click than type, the Security Advisor in the Supabase dashboard flags tables in the public schema that have RLS disabled.
02
Turn RLS on for every table
alter table public.notes enable row level security;Do it for every table in public, not only the ones that feel sensitive. According to Supabase’s API security guide, tables created in the dashboard’s Table Editor have RLS on by default, but tables created in the SQL Editor or by another tool need it turned on by hand. If an AI wrote your migrations, check.
Once RLS is on and a table has no policies yet, the public key can’t read anything from it. That is the safe way to fail. If part of your app goes blank, nothing is broken in a scary way. The next two steps give back exactly the access you want, and no more.
03
Trim the grants
Postgres checks two things before a request touches a table. Grants decide whether a role may run an operation on the table at all. Policies decide which rows that operation applies to. The Supabase docs point out that on many projects a new table in public starts out granting select, insert, update, and delete to both anon and authenticated, and that adding policies does not take those grants back.
-- Start from nothing for the two client roles...
revoke all on table public.notes from anon, authenticated;
-- ...then give back only what the app needs.
-- Here: signed-in users work with notes, signed-out visitors get nothing.
grant select, insert, update, delete on table public.notes to authenticated;Only revoke from anon if signed-out visitors truly don’t need the table. If your landing page reads from it, grant select back to anon as well and let a policy decide which rows they see, as in step 5.
04
Write one policy per operation, tied to auth.uid()
For data that belongs to a user, the rule is almost always the same: you may touch a row if its owner column matches the ID of the person asking. auth.uid() returns that ID. Write a separate policy for each operation, so you can see at a glance what each one allows.
-- Assumes public.notes has a user_id uuid column that holds the owner's auth.users id.
create policy "Owners can read their notes"
on public.notes for select
to authenticated
using ( (select auth.uid()) = user_id );
create policy "Owners can add their notes"
on public.notes for insert
to authenticated
with check ( (select auth.uid()) = user_id );
create policy "Owners can edit their notes"
on public.notes for update
to authenticated
using ( (select auth.uid()) = user_id )
with check ( (select auth.uid()) = user_id );
create policy "Owners can delete their notes"
on public.notes for delete
to authenticated
using ( (select auth.uid()) = user_id );
-- Policies filter on user_id for every query, so index it.
create index if not exists notes_user_id_idx on public.notes (user_id);A few details here that are easy to get wrong:
usingdecides which existing rows a user can see, change, or delete.with checkdecides what a new or changed row is allowed to look like. The update policy needs both, otherwise a user could edit their own row and setuser_idto someone else’s.to authenticatedkeeps the policy away from signed-out requests altogether. For a signed-out requestauth.uid()is null, so the comparison would fail anyway, but saying it out loud makes your intent obvious to the next person (or AI) who edits the table.- Wrapping the call as
(select auth.uid())is the form Supabase recommends. Postgres works it out once per query instead of once per row, which matters as tables grow. So does the index onuser_id.
05
Be deliberate about what is public
Some data really is public, like published blog posts or a product catalogue. That is fine. Say so explicitly, and keep the rule as narrow as the data.
-- A blog where anyone may read published posts, and nobody writes from the browser.
alter table public.posts enable row level security;
revoke all on table public.posts from anon, authenticated;
grant select on table public.posts to anon, authenticated;
create policy "Anyone can read published posts"
on public.posts for select
to anon, authenticated
using ( published = true );A policy that says using (true) for anon lets every stranger read every row. Use it only for data you would be happy to see on a billboard. Then list every policy you have and read them slowly:
-- Every policy in public. Read the qual and with_check columns slowly.
select tablename, policyname, cmd, roles, qual, with_check
from pg_policies
where schemaname = 'public'
order by tablename, cmd;One thing that catches people out: RLS works on whole rows, not columns. If one table mixes things that are fine to show (a display name) with things that are not (an email address, a payment status), the private columns travel with every row a policy lets through. Palmer’s write-up recommends splitting sensitive data into its own table with its own stricter policy. That is usually the cleanest fix.
06
Don’t base rules on user_metadata
It is tempting to store a role like admin in a user’s metadata and check it in a policy. The Supabase docs warn against using raw_user_meta_data for that, because a signed-in user can update it themselves. Authorization data belongs in raw_app_meta_data, which users can’t change, or in a table of your own that users can’t write to.
07
Check your views
Views are easy to forget. Supabase’s docs explain that a view usually runs with its creator’s rights, so by default it ignores the RLS on the tables underneath and can hand out every row your policies were meant to hold back. On Postgres 15 and later you can make a view respect those policies:
-- Postgres 15 and later: make the view respect the RLS of the tables it reads.
alter view public.my_view set (security_invoker = true);On older Postgres versions, revoke access to the view from anon and authenticated, or move it to a schema the API doesn’t expose.
08
Check your storage buckets
Files have the same problem as tables, with a twist. Supabase’s bucket docs say a public bucket skips access control for reading: anyone with a file’s URL can download it. That is right for avatars and product photos. It is wrong for invoices, ID scans, or anything a user would be upset to see on someone else’s screen. Buckets are private by default, so look at which ones were switched to public:
select id, name, public from storage.buckets;For private buckets, access is decided by RLS policies on storage.objects. A common pattern, adapted from Supabase’s storage access control guide, keeps each user inside a folder named after their user ID:
-- Private bucket "documents", one folder per user: documents/<user id>/file.pdf
create policy "Users upload into their own folder"
on storage.objects for insert
to authenticated
with check (
bucket_id = 'documents'
and (storage.foldername(name))[1] = (select auth.jwt()->>'sub')
);
create policy "Users read their own folder"
on storage.objects for select
to authenticated
using (
bucket_id = 'documents'
and (storage.foldername(name))[1] = (select auth.jwt()->>'sub')
);This assumes your app uploads files to paths like <user id>/file.pdf. If it doesn’t, change the upload path first, then add the policies.
09
Keep the service_role key off the client
There is a trap worth naming here. You turn on RLS, part of the app stops loading, and the quickest-looking fix is to swap in the service_role key because then everything loads again. It loads because that key ignores every rule you just wrote. If it ends up in the browser, your whole database is open again, this time with no rules at all.
- Keep it in server code only: an API route, an Edge Function, a background job. Any environment variable with a
NEXT_PUBLIC_orVITE_prefix gets bundled into the browser, so the secret key must never have one. Supabase’s API keys guide says the same. - Supabase is moving projects from the legacy anon and service_role keys to publishable and secret keys. The new secret keys are refused when a request comes from a browser, which helps, but the docs are clear that someone who copies a leaked key can still use it from other tools.
- If you think the key has shipped, fix the code first, then rotate the key. The API keys guide has the steps.
Test it yourself
Rules you haven’t tested are guesses. Here are three checks, from quickest to most thorough. Only run them against a project you own.
As a stranger
From your own terminal, ask for one row the way a visitor to your site could, using the public key from your bundle:
curl "https://YOUR-PROJECT.supabase.co/rest/v1/notes?select=*&limit=1" \
-H "apikey: YOUR_PUBLIC_KEY"- Real rows from a table that should be private: stop, and go back to step 2 for that table.
- An empty list,
[]: RLS filtered everything out, or the table is empty. Good, as long as you know which one. - A permission error (code
42501): theanonrole has no grant on the table. Also good, if that is what you intended.
If the response complains about the key itself rather than the table, sign out of your app, open the browser’s Network tab, reload, and find a request to /rest/v1/. Copy its headers into your command. Then you are sending exactly what a signed-out visitor sends.
As another user
Make two test accounts. Sign in as the second one and try to reach something that belongs to the first: open a URL with the first account’s record ID, or load a page that lists items. You should see nothing of theirs. This is the check that catches a policy that is on but too loose, which the stranger test can miss.
Every time, automatically
Supabase recommends writing pgTAP tests for each table and running them with supabase test db. The RLS docs include complete example test files you can adapt. Even without tests, re-run the audit query from step 1 after every big AI-assisted change. New tables have a way of appearing quietly.
Where LockShip fits
We built LockShip because the stranger’s-eye check is easy to forget and tedious to repeat. The free scan finds the Supabase URL and public key in your live bundle, makes read-only requests for at most one row from the tables it can see, and tells you the worst thing it found. It also looks for secret keys in your JavaScript and for exposed files like /.env.
It is honest to say what it doesn’t do, too. It doesn’t sign in as your users, it doesn’t test your storage policies, and it never writes to your database. It is a point-in-time, best-effort check, not a penetration test. A clean result means “nothing obvious from the outside right now”, not “safe”. Use it alongside the checklist above, not instead of it.
Want a second pair of eyes on step 1?
Paste your app URL, prove you own the domain, and see what a stranger can read. The summary is free.
Run a free scanBefore you rely on a result, read the disclaimer.
Sources
We checked each of these on 16 October 2026. Supabase’s docs change often, so if something here disagrees with them, trust the docs and tell us at hi@lockship.dev.