GUIDE / 2026-07-22
Supabase RLS for Vibe Coders: Lock Down Your Database
By Muhammed Shibli Published Updated
TL;DR
- Your Supabase anon key ships in the browser bundle on purpose. Without RLS, that key can read and write every row in every table.
- Enabling RLS with no policies blocks everything; each policy then re-opens exactly one path. That's the mental model.
- Four copy-paste policies cover 90% of apps: own-rows select, insert, update, and delete keyed on auth.uid().
- Always test policies with a second account. A wrong policy fails silently — everything looks fine to you and is open to everyone.
Here is the uncomfortable mechanic at the heart of every Supabase-backed vibe-coded app: your database credentials are public. The VITE_SUPABASE_ANON_KEY in your frontend ships to every visitor’s browser — that’s how Supabase works, and it’s fine. It’s fine because Postgres Row Level Security is supposed to be the actual gatekeeper, deciding row by row what each request may touch.
Which means: if RLS is off, anyone can query your entire database using credentials you gave them. Not a sophisticated attacker — anyone who opens dev tools, copies the URL and key, and writes five lines of JavaScript. When I audit AI-built apps, missing or broken RLS is the most common critical finding, ahead of leaked API keys.
What RLS actually is
Row Level Security is a Postgres feature: per-table rules that filter every query, no matter who sends it or through what client. Think of each policy as an invisible WHERE clause the database appends on your behalf.
The mental model that makes everything click:
- RLS disabled (Postgres default): the table is fully open to anyone with a key.
- RLS enabled, no policies: the table is fully closed — even your own app’s reads return empty.
- Each policy re-opens exactly one path: “authenticated users may SELECT rows where
user_idmatches their own id.”
Enabling RLS is one line. Policies are where the design lives.
Why AI builders skip it
Not malice — incentives. The generated app works perfectly in your testing without RLS, because you are the only user and there’s nothing to protect against. RLS adds no visible feature, and a wrong policy breaks the demo, so the safest path to “the app works” is to leave the door open. The failure only appears when a second, unfriendly user shows up — which is exactly when you can least afford it.
Auditing note, tested by author, July 2026: of the last ten Supabase-backed apps I reviewed, most had at least one table with RLS disabled or a policy equivalent to using (true) on data that was clearly per-user. This is the normal state of vibe-coded apps, not the exception.
Find your exposed tables (2 minutes)
Run this in the Supabase SQL Editor:
select tablename, rowsecurity as rls_enabled
from pg_tables
where schemaname = 'public'
order by rowsecurity, tablename;
Every row with rls_enabled = false is fully open through your public API. Supabase’s dashboard also surfaces this — Database → Tables shows an “RLS disabled” warning — but the query gives you the complete list at once.
The core policies (copy-paste)
Assume a classic notes table where each row belongs to a user:
create table notes (
id uuid primary key default gen_random_uuid(),
user_id uuid not null default auth.uid() references auth.users(id),
title text not null,
body text,
created_at timestamptz not null default now()
);
Step one, close the door:
alter table notes enable row level security;
Step two, re-open the four paths a user needs — and note the (select auth.uid()) wrapping, which lets Postgres evaluate the function once per query instead of once per row:
-- Read your own rows
create policy "notes: select own"
on notes for select
to authenticated
using ((select auth.uid()) = user_id);
-- Create rows only as yourself
create policy "notes: insert own"
on notes for insert
to authenticated
with check ((select auth.uid()) = user_id);
-- Update only your own rows
create policy "notes: update own"
on notes for update
to authenticated
using ((select auth.uid()) = user_id)
with check ((select auth.uid()) = user_id);
-- Delete only your own rows
create policy "notes: delete own"
on notes for delete
to authenticated
using ((select auth.uid()) = user_id);
The distinction people trip on: using filters which existing rows the operation may touch; with check validates the rows being written. UPDATE needs both — otherwise a user could update their own row to belong to someone else, or vice versa.
Finally, index what your policies filter on:
create index notes_user_id_idx on notes (user_id);
The patterns that cover real apps
| Pattern | Policy shape |
|---|---|
| Private, per-user data (notes, settings, chats) | The four “own rows” policies above |
| Public read, owner write (blog posts, profiles) | for select to anon, authenticated using (true) + owner-only insert/update/delete |
| Team/workspace access | using (team_id in (select team_id from team_members where user_id = (select auth.uid()))) |
| Admin-only tables | Policy checking a role claim, or no policies at all — access only via service_role from server code |
Two warnings on these. Public-read policies (using (true) on SELECT) are correct only for genuinely public data — I regularly find them on tables containing emails and phone numbers, where “the app needed to show the profile page” became “everyone can enumerate all users.” And team policies that subquery a membership table should wrap the check in a security definer function if the membership table itself has RLS, or you’ll chase recursive policy errors.
Testing policies (the step everyone skips)
A wrong policy fails invisibly: your app works for you, and the hole only exists for other people. Test with two accounts, always.
Fastest: impersonation in SQL. The SQL Editor can execute as an arbitrary role and user:
-- Simulate an authenticated request from a specific user
set local role authenticated;
set local request.jwt.claims to '{"sub": "PASTE-OTHER-USERS-UUID-HERE"}';
select * from notes; -- should return ONLY that user's rows
reset role;
Most realistic: two browsers. Sign up a second account in an incognito window and, from its session, try to fetch the first account’s data directly:
// In the second user's browser console
const { data } = await supabase.from('notes').select('*');
console.log(data); // must NOT contain user 1's notes
Also test the negative space: with RLS correct, a malicious insert with someone else’s user_id should fail, and an update to a row you don’t own should affect 0 rows.
One last key: service_role
RLS applies to the anon and authenticated roles. The service_role key bypasses it completely — it exists so your server-side code can do admin work. Treat it like a root password: server environments only, never in the repo, never VITE_-anything. If git log -S service_role --oneline finds anything in your history, rotate it now.
RLS is one of twenty things I verify in a production audit — but it’s the one that most often stands between a vibe-coded app and a public data leak. Close the door first, then go build features.
Section / FAQ
Questions people ask
- Is it safe that my Supabase anon key is visible in the browser?
- Yes — by design, but only if RLS is enabled with correct policies on every table in exposed schemas. The anon key is meant to be public, like a street address. RLS is the lock on the door. Public key + no RLS = database open to anyone who opens dev tools.
- Why didn't Lovable/Bolt/Cursor enable RLS for me?
- Sometimes they do, inconsistently. AI tools generate the schema you asked for, and RLS policies are easy to omit because the app works identically in your own testing without them. Newer Lovable versions warn about missing RLS, but generated policies still need review — I regularly find over-permissive ones like `using (true)` on sensitive tables.
- Does RLS slow down my queries?
- Policies run on every row access, so a badly-written policy can hurt. Two fixes cover almost every case: wrap function calls so they're evaluated once per query — `(select auth.uid())` instead of `auth.uid()` — and index the columns your policies filter on, like user_id. With those, RLS overhead is negligible for typical app workloads.
- What's the difference between the anon key and the service_role key?
- The anon key is public and subject to RLS. The service_role key bypasses RLS entirely — it's root for your database. It must never appear in frontend code, VITE_ variables, or the browser. Server-side only (edge functions, your own backend), and rotate it immediately if it was ever committed to git.
- Do I need RLS if my app has no login?
- Yes, arguably more. With no auth, every visitor is anonymous, so any table without RLS is fully readable and writable by the public. For read-only public content, enable RLS and add a select-only policy for anon — that way writes are blocked even though reads are open.
Free tool
Run the free security checklist →
RLS is check #9 of 20. See how the rest of your app scores.