RLS misconfiguration, explained
Row Level Security is the only thing between your public anon key and your data. How it fails, why a policy can exist and deny nothing, and how to test it.
- security
- vibe coding
- engineering
Row Level Security misconfiguration is the vulnerability class most likely to be in a vibe-coded app, and the one least likely to be understood by the person who shipped it. It has a CVE, a body count of confirmed production apps, and a mechanism that takes four paragraphs to explain, which is probably why most posts about it skip to the SQL.
The SQL is at the bottom. The mechanism is the part that stops you from reintroducing it.
What RLS actually is
Postgres normally answers a query based on who is connected. Row Level Security moves the decision down to the row: a policy is a boolean expression evaluated per row, and rows where it is false do not exist as far as that query is concerned. It is a filter the database applies whether or not the application remembered to.
That property is the whole reason it matters here. Every other access control in your app is code that has to run. RLS is a rule the database enforces even when the code that should have filtered was never written.
Why the public key is only safe because of it
Supabase, which is what Lovable, Bolt, and most AI builders put underneath your
app, exposes your Postgres tables directly over a REST API via PostgREST. Your
frontend talks to that API using the anon key, and the anon key ships inside
your JavaScript bundle. It is public. Anyone can open DevTools and read it.
Supabase’s documentation is
comfortable calling it safe to expose, and that is correct, with one condition
that is easy to read past: the key grants the anon Postgres role, and what that
role can see is determined entirely by your policies. RLS is not one layer of
several. On a Supabase project it is the layer.
So the chain runs like this. Your table has no policy, so nothing filters. Your anon key is in the bundle, so everyone has it. PostgREST answers anyone holding the key. Therefore your table is a public API endpoint, and your login screen, which is real and works, is decorative:
# No account, no session, no exploit. Just the key from the bundle.
curl -s "https://<project-ref>.supabase.co/rest/v1/profiles?select=*" \
-H "apikey: <anon-key>"
That is CVE-2025-48757, the CVSS 9.3 disclosure from May 2025 in which Lovable-generated projects shipped tables with missing or insufficient RLS. More than 170 deployed apps were confirmed leaking emails, API keys, and payment records.
The three ways it goes wrong
RLS is off. The default for a newly created table. Generators create tables and do not enable it, so the app works immediately and the table is world readable. This is the majority of real cases.
RLS is on and the policy permits everything. This is the interesting one. Enabling RLS with no policy denies all access, which breaks the app instantly. The fastest way to make a broken app work again is:
-- Do not do this. It is RLS with the security removed.
create policy "allow all" on public.documents
for all using (true);
The table now reports rowsecurity = true. A dashboard check passes. A scanner
that looks for the existence of a policy passes. And the data is exactly as
exposed as it was before. Lovable’s own security scanner, released in April 2025
while the CVE window was open, had precisely this shape: as the researcher
documented,
it “merely checks for the existence of any RLS policy, not its correctness or
alignment with application logic.”
RLS is on, the policy is right, and something bypasses it. The Supabase
service_role key carries the BYPASSRLS attribute and, in Supabase’s words,
skips “any and all Row Level Security policies you attach.” If that key reaches
a browser, every policy in your database is decoration. This is the standard
outcome of putting it behind a client-exposed environment variable prefix, which
is how it happens on Bolt. Postgres superusers
and table owners bypass RLS too unless the table is set to force row level security, which matters if your backend connects as the owning role.
Writing a policy that means something
Two statements per table, together:
alter table public.documents enable row level security;
create policy "owner reads own documents"
on public.documents
for select
using (auth.uid() = user_id);
create policy "owner writes own documents"
on public.documents
for insert
with check (auth.uid() = user_id);
create policy "owner updates own documents"
on public.documents
for update
using (auth.uid() = user_id)
with check (auth.uid() = user_id);
using and with check are not interchangeable and the difference bites on
updates. using decides which existing rows the statement may touch. with check decides what the row is allowed to look like afterwards. An update policy
with only using lets a user take a row they own and reassign user_id to
someone else, which is a data write into another account. Both clauses, every
time.
Test the policy, do not inspect it
Reading your own SQL confirms what you meant. Only a request confirms what the database does. This is the entire lesson of the Lovable scanner: existence is not correctness, and the only check that cannot lie is the attack itself.
Inventory first:
select tablename, rowsecurity
from pg_tables
where schemaname = 'public';
select tablename, policyname, cmd, qual, with_check
from pg_policies
where schemaname = 'public';
Read qual and with_check. A true in either column on a table holding user
data is the permissive policy wearing a policy’s clothes.
Then attempt the read with nothing but the public key:
curl -s "https://<project-ref>.supabase.co/rest/v1/documents?select=*" \
-H "apikey: <anon-key>"
[] is a pass. Rows are a launch blocker.
Then, harder, log in as user A, take the id of a row belonging to user B, and request it with A’s session token. It should come back empty. If it does not, your policy exists and does not scope, which is the failure mode nobody tests for.
Where you will meet this
Any Supabase project, whoever wrote it. It reached CVE status through Lovable and it is not a Lovable bug: it is the interaction between a public API key and a default-off row filter, and it applies identically to a Bolt.new project, a Replit app on Supabase, and one you typed yourself.
It also sits at the center of the layer model, which is why it is worth
understanding rather than pattern-matching. It is a configuration finding, since
the table setting is wrong. It is an access control finding, since the effect is
that anyone reads anything, the same class as
IDOR. And it becomes a secrets finding the moment a
service_role key leaks and makes the whole discussion moot. One
misconfiguration, three
layers, which is why checking it from
inside a single dashboard tab keeps producing green results on exposed data.
The four-line version, if you skipped here: turn it on for every table, write the
read and write policies together, never leave using (true) in a policy on user
data, and prove it with an unauthenticated request instead of a checkmark.