← All guides

Supabase RLS Checklist for Vibe Coders

Updated 2026-10-14

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.

☐ 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

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:

☐ 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

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.

Check your repo in about a minute

Paste a public GitHub URL and get a free launch score.

Run a free scan

More guides