If you built your app with Lovable, Bolt, Cursor or v0 on top of Supabase, Row Level Security (RLS) is the single most important thing to get right before launch. Your frontend talks to the database directly with a public key, so RLS is what stands between a stranger and your users' data.
This checklist is meant to be worked through top to bottom. Each item says what to check, why, and what the fix looks like.
Why RLS matters so much in Supabase
Supabase exposes tables in your public schema through an auto-generated REST API (PostgREST). Requests made with the anon key run as the anon role; requests from a logged-in user run as authenticated. Postgres then applies RLS policies to every query.
- RLS off: the table is governed only by table-level grants, and in a default Supabase project
anonandauthenticatedhave broad grants onpublictables. In practice, that usually means anyone can read and write it. - RLS on, no policies: nobody except the service role (and table owners) can access it. Safe, but your app won't work until you add policies.
- RLS on with policies: each query is filtered by your rules. This is what you want.
☐ 1. Find every table with RLS disabled
select n.nspname as schema, c.relname as table, c.relrowsecurity as rls_on
from pg_class c
join pg_namespace n on n.oid = c.relnamespace
where c.relkind = 'r' and n.nspname = 'public'
order by rls_on, c.relname;
The Supabase dashboard also flags these, and its Security Advisor reports "RLS disabled in public" as an error. Treat it as a launch blocker.
☐ 2. Enable RLS in your migrations, not just the dashboard
If you click "Enable RLS" in the dashboard but your migrations create the table without it, a fresh environment (or a teammate's local setup) will be wide open. Put it in the migration next to create table:
create table public.notes (
id uuid primary key default gen_random_uuid(),
user_id uuid not null references auth.users (id) on delete cascade,
body text not null,
created_at timestamptz not null default now()
);
alter table public.notes enable row level security;
PreflightPR's RLS check reads your supabase/migrations folder and flags tables that are created without a matching enable row level security.
☐ 3. Write one policy per operation
Separate policies for select, insert, update and delete are easier to reason about than one for all policy.
create policy "notes: owner can read"
on public.notes for select to authenticated
using ((select auth.uid()) = user_id);
create policy "notes: owner can insert"
on public.notes for insert to authenticated
with check ((select auth.uid()) = user_id);
create policy "notes: owner can update"
on public.notes for update to authenticated
using ((select auth.uid()) = user_id)
with check ((select auth.uid()) = user_id);
create policy "notes: owner can delete"
on public.notes for delete to authenticated
using ((select auth.uid()) = user_id);
☐ 4. Understand using vs with check
usingdecides which existing rows a query can see or touch (select, update, delete).with checkdecides whether a new or changed row is allowed (insert, update).
An update policy with only using lets a user take a row they own and change its user_id to someone else's. Always pair using and with check on updates.
☐ 5. Look for policies that allow everything
Search for these patterns in your policies:
select schemaname, tablename, policyname, roles, cmd, qual, with_check
from pg_policies
where schemaname = 'public'
and (qual = 'true' or with_check = 'true');
using (true) is sometimes correct, for a public list of blog posts, say. It is never correct for profiles, orders, messages or anything personal. AI tools often generate a permissive policy just to make an error go away, so check every one of them.
☐ 6. Specify the role with to
A policy without to applies to public, which includes anon. If only logged-in users should have access, say so: to authenticated. It's both clearer and slightly faster, because Postgres skips the policy for other roles.
☐ 7. Wrap auth.uid() in a select
Writing (select auth.uid()) instead of auth.uid() lets Postgres evaluate it once per query instead of once per row. Supabase recommends this for performance on larger tables. Also add an index on the column you compare against:
create index on public.notes (user_id);
☐ 8. Keep privileged columns out of user-writable rows
If users can update their own profiles row, and profiles has an is_admin or plan column, they can make themselves admin. Options:
- Move privileged fields to a separate table with no user write policies.
- Or use column-level privileges:
revoke update (is_admin) on public.profiles from authenticated;
☐ 9. Don't base policies on user_metadata
auth.jwt() -> 'user_metadata' is editable by the user through supabase.auth.updateUser(). Never use it for authorization. Use app_metadata (only settable server-side) or, better, a table you control.
☐ 10. Check views and functions
- Views run with the permissions of their owner by default, which usually bypasses RLS. On Postgres 15+ create them with
with (security_invoker = true)so the caller's RLS applies, or keep them out of the exposed schema. security definerfunctions run as their owner and bypass RLS. Keep them in a non-exposed schema if possible, setsearch_path, and checkauth.uid()inside.
create view public.my_notes with (security_invoker = true) as
select id, body from public.notes;
☐ 11. Lock down Storage
Storage access is controlled by RLS on storage.objects. For per-user private files:
create policy "users read own files"
on storage.objects for select to authenticated
using (
bucket_id = 'private-files'
and (storage.foldername(name))[1] = (select auth.uid())::text
);
Also check which buckets are marked public: their files are downloadable by anyone with the URL, regardless of policies.
☐ 12. Keep service_role on the server
The service role key bypasses RLS entirely. It belongs in Edge Functions or your server environment only, never in a NEXT_PUBLIC_ or VITE_ variable. If it has ever been in client code or a public repo, rotate it.
☐ 13. Test as a real user
Don't trust the SQL editor for testing; it runs as a superuser and ignores RLS. Instead, sign in as two different test users in your app (or use the anon key with curl) and try to read each other's data:
curl "https://YOUR-PROJECT.supabase.co/rest/v1/notes?select=*" \
-H "apikey: YOUR_ANON_KEY"
With correct policies, an anonymous request returns [] (or an error), not your data.
Run the check automatically
Going through this by hand once is worth it. Doing it on every push is better. PreflightPR checks your Supabase migrations for tables without RLS, along with leaked keys, unprotected API routes and Stripe webhook problems. On our own deliberately vulnerable sample app it found 16 issues, 12 critical.
Run a free scan on a public repo to see your launch score in about a minute. It's an automated best-effort review, not a penetration test, so treat it as a safety net alongside the checklist above.