“A filing cabinet in a public lobby with a 'staff only' sign, and no lock.”
The client is not on your side
Modern AI tools love a pattern where the browser talks straight to a hosted database like Supabase or Firebase. It's fast to build and it demos beautifully. The problem is the security model: the browser ships with a key to the database, and the browser is fully under the user's control. Anyone can open developer tools, copy that key, and query the database directly, bypassing your app, your screens, and any check that lived in the front-end code.
browser (any user) your UI says database ┌───────────────┐ 'you may see ┌─────────────┐ │ anon key ────┼─────────── your row' ────X ───▶│ every row │ │ devtools open │ (front-end only) │ for everyone │ └───────────────┘ ◀─────── returns ALL rows ────└─────────────┘
So a rule enforced in your React component, "only show the current user's orders," is not a security control. It's a display preference. The database will happily hand the same data to a raw query that never loads your component at all. The access decision has to live where the user can't reach it: in the database itself.
What Row-Level Security actually is
Row-Level Security (RLS) moves the access decision into the database, per row. With it on, the database refuses every request by default, and you write policies that say exactly which rows a given user may see or change. The classic policy: a user may read a row only if the row's owner column equals their authenticated id. Now the direct query that bypassed your UI gets back nothing, because the database, not the browser, is deciding.
-- Turn RLS on, THEN add a policy. RLS on with no policy = deny everything.
alter table orders enable row level security;
create policy "owners read their own orders"
on orders for select
using ( (select auth.uid()) = user_id );That's the whole idea: deny by default, then grant narrowly. It's the same principle as least privilege everywhere else: start from "no one can touch anything" and open the smallest door that makes the feature work.
The mistakes that still leak with RLS 'on'
Turning RLS on is necessary and not sufficient. These are the ways apps leak anyway, all of which an AI tool will cheerfully produce:
- RLS enabled on some tables, forgotten on others: the table you added last week has no rules and is wide open.
- A policy so permissive it grants everything (using (true)), which is the same as no policy with extra steps.
- Shipping the service-role / admin key to the client: that key bypasses RLS entirely, so every policy is moot.
- Checking ownership against a value the client sent, instead of the server-verified auth id: the client just sends a different value.
How to actually check
Don't trust that it's done because the toggle is on. Verify it the way an attacker would:
- List every table. Confirm RLS is enabled AND has a real, scoped policy on each, not just enabled.
- Query as an anonymous user with the public key and confirm you get nothing you shouldn't.
- Log in as a second user and confirm you cannot read or write the first user's rows.
- Search your client bundle and env config for the service-role/admin key. It should not be there.
This is fifteen minutes of work and it's the highest-leverage security check you can run on an AI-built app. The Lovable CVE was, at bottom, an industry-scale version of skipping it.
In one line each
- Browser-to-database patterns ship a key the user controls; any check in the front end is decoration, not security.
- Row-Level Security moves the decision into the database, per row: deny by default, then grant narrowly.
- RLS 'on' still leaks via forgotten tables, over-permissive policies, a client-side service-role key, or trusting client-sent ids.
- Verify like an attacker: every table has a real policy, anon gets nothing, a second user is blocked, no admin key in the client.
Where to go next