I train non-developers to build real applications with tools like Lovable, and part of my job is reviewing what they've built before anyone deploys it near actual user data. After a year of these reviews, I can tell you the security story of the vibe coding era in one sentence:
The frontend looks done, so everyone assumes the backend is.
The numbers back up what I see in training rooms. Researchers who scanned Lovable-generated apps found critical row-level security flaws in roughly 10 percent of them. A broader scan of 1,400+ vibe-coded production apps found security issues in about two thirds, including several hundred exposed secrets. This isn't a corner-case problem. It's the default outcome when the tooling optimizes for visible progress and the security layer is invisible.
So here's the deep dive I wish every AI-app builder would read before connecting a database. It's Supabase-specific because that's what the popular tools scaffold, but the mindset transfers.
The mental model most people are missing
Supabase isn't a backend hiding behind your API routes. It exposes your Postgres database directly to the browser via PostgREST. Your frontend talks to the database. That anon key sitting in your client bundle? It's public by design. Anyone can open DevTools, copy it, and fire requests at your database from curl.
Read that again if you're new here, because everything else follows from it: there is no server between your users and your tables. Row Level Security is not an optional hardening step. It's the only wall that exists.
This is also why the AI tools get it wrong so often. The generated frontend code politely filters data:
// This is a UI feature, not security
const { data } = await supabase
.from('invoices')
.select('*')
.eq('user_id', user.id);
Looks correct. Works in the demo. And any user can drop the .eq() filter in their own request and read every invoice in the table, unless the database itself refuses. The filter runs in the attacker's browser. The attacker is under no obligation to keep it.
First: find out how exposed you are right now
Run this in the Supabase SQL editor. It lists every table in your public schema with RLS switched off:
select schemaname, tablename
from pg_tables
where schemaname = 'public'
and not rowsecurity;
Every row this returns is a table that anyone with your (public) anon key can potentially read and write in full. In the reviews I do, this query almost never comes back empty on the first run. Common reason: the AI created tables through the SQL editor or a migration, and while Supabase enables RLS on tables created through its UI, generated migrations sometimes skip it. Nobody notices, because the app works. Of course it works. Everything is allowed.
While you're there, check what policies actually exist:
select tablename, policyname, cmd, qual, with_check
from pg_policies
where schemaname = 'public'
order by tablename;
The five failure patterns I keep finding
1. RLS enabled, policy says yes to everyone.
AI assistants, when asked to "fix" access errors, love generating this:
create policy "Enable read access for all users"
on invoices for select
using (true);
using (true) means every row, every requester. Sometimes that's genuinely right (a public blog posts table). On an invoices table it's a data breach with a checkmark next to it. Grep your policies for true and interrogate each one.
2. SELECT is locked down, writes are wide open.
Policies are per-command. A policy for select does nothing for inserts, updates or deletes. I've reviewed apps where reading was properly restricted and any authenticated user could update any other user's rows, because nobody wrote the other three policies. If this query shows only SELECT in the cmd column for a table users write to, you have a problem:
select tablename, cmd, count(*)
from pg_policies
where schemaname = 'public'
group by tablename, cmd;
3. Missing WITH CHECK.
using governs which existing rows you can see or touch. with check governs what a row is allowed to look like after an insert or update. Skip it, and a user can insert rows with someone else's user_id, or update their own row and reassign it. The complete set for a standard user-owned table looks like this:
alter table invoices enable row level security;
create policy "read own" on invoices
for select using ((select auth.uid()) = user_id);
create policy "insert own" on invoices
for insert with check ((select auth.uid()) = user_id);
create policy "update own" on invoices
for update using ((select auth.uid()) = user_id)
with check ((select auth.uid()) = user_id);
create policy "delete own" on invoices
for delete using ((select auth.uid()) = user_id);
Side note on the (select auth.uid()) wrapping instead of bare auth.uid(): Postgres can then evaluate it once per query instead of once per row. On large tables this is the difference between fine and mysteriously slow.
4. Views that quietly bypass everything.
This one bites even experienced developers. Views in Postgres execute with the permissions of their creator by default, not the caller. Your table has beautiful RLS, then the AI generates a convenient invoice_summary view, and that view serves everyone's data to anyone. On Postgres 15+, fix it like this:
alter view invoice_summary set (security_invoker = true);
Audit every view the tool created for you. There are usually more than you remember approving.
5. The service role key in the wrong place.
The service_role key bypasses RLS entirely. That's its purpose, for trusted server-side jobs. I've found it in client-side code, in committed .env files pushed to public repos, and once, memorably, pasted into a prompt that ended up in a shared chat log. If that key leaks, your policies are decoration. Rotate it if you're even slightly unsure where it's been.
Test like an attacker, not like a user
The single highest-value habit: test your API with the anon key from outside your app. No UI, no client library, just raw requests:
curl "https://YOUR-PROJECT.supabase.co/rest/v1/invoices?select=*" \
-H "apikey: YOUR_ANON_KEY" \
-H "Authorization: Bearer YOUR_ANON_KEY"
Then again with a logged-in user's JWT of a different account, trying to read and write rows that aren't theirs. Empty results and permission errors are what success looks like. In my trainings this exercise takes 20 minutes and produces more genuine understanding than two hours of me talking, because watching your own app hand over someone else's data via curl is an experience nobody forgets.
One warning for the AI-assisted crowd: when your assistant hits an RLS permission error during development, it will frequently propose "solving" it by loosening the policy or disabling RLS. That suggestion reliably makes the error go away. So would removing the front door of your house. Treat every AI-suggested policy change as hostile until you've read it yourself.
The uncomfortable summary
None of this is advanced. Four policies per table, careful views, keys in the right place, and a curl test. A competent reviewer covers a typical vibe-coded app in an hour or two.
That's exactly why the current statistics annoy me. We're not facing some unsolvable frontier problem. We're facing a tooling generation that ships the visible 90 percent brilliantly and leaves the invisible 10 percent to people who've never heard the phrase "row level security." The tools will get better at this, some are already adding checks. Until then, the fix is an afternoon of SQL and a healthy distrust of anything that works on the first try.
If you've found other RLS footguns in generated apps, drop them in the comments. I'm collecting them for my trainings, and the weirdest one so far involved a policy checking user_id = user_id. Which is, if you think about it, always true. The AI was very confident about it.
Top comments (0)