Guides / Supabase Row Level Security for vibe-coded apps
Supabase Row Level Security for vibe-coded apps
Lovable, Bolt, v0 and most AI app builders put Supabase behind your frontend and your key in the browser. Row Level Security is the only thing standing between that key and your data.
Why RLS is not optional
Your Supabase publishable (or legacy anon) key ships in your frontend, and Supabase says that is fine, as long as RLS is on. Anyone can take that key and call your database API directly, skipping your UI entirely. Requests with the publishable key run as the anon role when signed out and authenticated when signed in. Policies decide what those roles can do.
Grants vs policies
Postgres checks two things. Grants decide whether a role can run an operation on a table at all. Policies decide which rows. Supabase notes that on many projects new tables in public automatically grant select, insert, update and delete to anon and authenticated, and that adding policies does not remove those grants. Their recommended setup does both:
alter table public.reports enable row level security; revoke all on table public.reports from anon, authenticated; grant select, insert, update, delete on table public.reports to authenticated;
Once RLS is enabled, publishable-key clients get nothing until you add policies.
Audit what you have
Run in the Supabase SQL Editor:
-- 1. Which public tables have RLS off? select tablename, rowsecurity from pg_tables where schemaname = 'public' order by rowsecurity, tablename; -- 2. What do the policies actually allow? select tablename, policyname, roles, cmd, qual, with_check from pg_policies where schemaname = 'public' order by tablename, cmd;
The owner-only pattern
Write one policy per operation. Postgres does not accept several operations in one for clause, and Supabase advises against for all because it hides intent.
create policy "Users can view their own profile." on profiles for select to authenticated using ( (select auth.uid()) = user_id ); create policy "Users can create their own profile." on profiles for insert to authenticated with check ( (select auth.uid()) = user_id ); create policy "Users can update their own profile." on profiles for update to authenticated using ( (select auth.uid()) = user_id ) with check ( (select auth.uid()) = user_id ); create policy "Users can delete their own profile." on profiles for delete to authenticated using ( (select auth.uid()) = user_id );
usingfilters existing rows.with checkvalidates the new row. For update, if you omitwith check, Postgres reuses theusingexpression, so ownership changes are still blocked. The danger is an explicitwith checkthat is weaker, liketrue. Writing both is clearer.- RLS is row-level: an owner can still change any column on their own row (e.g.
is_admin). Restrict columns withgrant update (col, ...). - Permissive policies are OR-ed; one
using (true)opens everything.as restrictivepolicies are AND-ed. - An update also needs a matching
selectpolicy to work. - Always name the role with
to. - Add an index on
user_id:create index on profiles using btree (user_id);
Public-read tables
alter table public.announcements enable row level security; revoke all on table public.announcements from anon, authenticated; grant select on table public.announcements to anon, authenticated; create policy "Anyone can read announcements" on public.announcements for select to anon, authenticated using ( true );
using (true) is only safe when the data is meant to be public and there is no write grant.
Mistakes we see constantly
- Views leaking data. Views bypass RLS by default. On Postgres 15+, create them
with (security_invoker = true). - Roles in
user_metadata. Users can edit it. Useapp_metadatafor authorization data. - Secret key in the browser. The
service_rolerole hasBYPASSRLS; no policy will save you. security definerfunctions in an exposed schema. They are callable over the API with the creator's privileges. Put them in a non-exposed schema and setsearch_path = ''.- Recursive policies (error
42P17), usually from sharing features where two tables' policies read each other.
Prove it with tests
Supabase recommends a pgTAP test file per table that asserts allow and deny for each operation and role:
supabase test new profiles_rls.test supabase test db
Use set local role anon / authenticated and set local request.jwt.claim.sub = '<user id>' to impersonate, and check denied writes with returning rather than lives_ok, which passes on zero rows. The official page has full example files.
next step
Want a second pair of eyes?
Grab the free Lockdown Pack (security rules for Cursor and Claude Code plus a pre-launch checklist), or have us check the whole app for a fixed $99.
Sources
Every product step above was checked against these official pages. Last checked Oct 2026. Products change; if a menu moved, the docs below are the source of truth.