Supabase RLS Explained: The Security Hole in Most Lovable Apps
Supabase RLS explained: why AI-built Lovable apps leak data, how to check your tables in 10 minutes, and what correct policies look like.
- Published
- min read
- 8 min read
On this page
Supabase RLS (Row Level Security) is the Postgres feature that decides which rows each user can read or change. In a Supabase app the browser talks to the database directly with a public key, so if RLS is off or a policy is too loose, anyone who opens your site's network tab can read or edit your tables. That one gap is the most common serious security problem I look for in apps built with Lovable and similar AI tools.
This guide explains what Supabase row level security actually does, why AI-built apps get it wrong, how to check your own project in a few minutes, and what correct policies look like.
What is Supabase RLS, in plain English?
Most traditional web apps have a backend server sitting between the user and the database. The server checks who you are, then runs only the queries you are allowed to run. Supabase works differently. Its Data API lets your frontend query the database directly, which is why tools like Lovable can build a working app so quickly.
The trade-off is that the security checks have to live inside the database itself. That is the job of RLS. You write a policy for a table, and Postgres quietly adds that rule to every query against it, much like an extra WHERE clause you can't remove from the client side.
So the mental model is simple:
- No RLS on a table in an exposed schema: any role that has been granted access to that table can read and write every row.
- RLS on, no policies: requests made with the public key get nothing back.
- RLS on, good policies: each user sees and changes only their own data.
Why the public key is not the problem
A lot of founders panic when they find their Supabase key sitting in the browser's JavaScript. For the publishable key (or the older anon key), that is expected. Supabase's own docs say the publishable key is designed to be shipped in browsers and mobile apps, and that RLS is what limits what it can reach.
The keys that must never reach the browser are the secret key (sb_secret_...) and the legacy service_role key. Both bypass RLS completely because the role behind them has the BYPASSRLS attribute. If one of those is in your frontend bundle, your policies don't matter, because the attacker can skip them.
| Key | Safe in the browser? | Respects RLS? |
|---|---|---|
Publishable (sb_publishable_...) | Yes | Yes |
Legacy anon (JWT starting with eyJ) | Yes | Yes |
Secret (sb_secret_...) | No, backend only | No, bypasses it |
Legacy service_role | No, backend only | No, bypasses it |
In other words, a visible public key is fine. A visible public key plus a table without proper RLS is a data leak.
The security hole in most Lovable apps
This isn't a theoretical risk. In May 2025 a CVE was published, CVE-2025-48757, describing insufficient Row Level Security policies in sites generated by Lovable up to 15 April 2025. According to the CVE record, the issue let remote attackers with no login read or write arbitrary tables in affected apps, and it carries a CVSS 3.1 score of 9.3 (critical). The record is marked as disputed: Lovable's position is that each customer is responsible for protecting the data in their own app.
Whoever you think is responsible, the practical lesson is the same. AI builders generate tables and queries fast, but they don't always generate the access rules that should come with them, and a non-technical founder has no reason to notice. The app works perfectly in testing, because the missing check only shows up when someone who isn't you sends a request.
The patterns I see most often in AI-built Supabase projects:
- Tables created through SQL or migrations with RLS never switched on. Supabase notes that tables made in the dashboard get RLS by default, but tables created with SQL or other tools need it enabled explicitly. AI tools usually work through SQL.
- "Allow everything" policies. A policy like
using (true)makes RLS look enabled while exposing every row. It's correct only for genuinely public data, like a list of blog posts. - Policies on SELECT only. Reads are locked down, but INSERT, UPDATE or DELETE are wide open, so anyone can change other users' records.
- Authorization based on user-editable data. Checking a role stored in
raw_user_meta_datais risky, because users can change that field themselves. Supabase's docs point toraw_app_meta_datafor anything used in authorization. - A secret or
service_rolekey in the frontend, often added by the AI to "fix" a permission error. - Views that ignore RLS. Views run as the
postgresrole by default, so they can expose rows the underlying table would hide. On Postgres 15 and later you can setsecurity_invoker = trueon the view.
How do you check if your Supabase app is secure?
You can do a first pass yourself in about ten minutes. Open the SQL editor in your Supabase dashboard and list every table in the public schema with its RLS status:
select tablename, rowsecurity
from pg_tables
where schemaname = 'public'
order by tablename;
Any row where rowsecurity is false is a table the public key may be able to reach. Next, list the policies you do have, so you can spot true conditions and missing operations:
select tablename, policyname, cmd, roles, qual, with_check
from pg_policies
where schemaname = 'public'
order by tablename, cmd;
Then test the app the way an attacker would. Log out, open your browser's developer tools on your live site, and try to read a table that should be private using only the public key:
import { createClient } from '@supabase/supabase-js'
// Same URL and publishable key your frontend already ships
const supabase = createClient(SUPABASE_URL, SUPABASE_PUBLISHABLE_KEY)
const { data, error } = await supabase.from('orders').select('*')
console.log(error ?? `${data.length} rows visible while logged out`)
If a private table returns rows while you're logged out, you have found the hole. Repeat the test while logged in as a second test account and try to read or update the first account's rows.
Finally, search your built frontend code and your repo for sb_secret_, service_role and any long eyJ... string, and confirm none of them are the elevated key.
What does a correct RLS policy look like?
Here is a typical example: a projects table where each row belongs to one user through an owner_id column. Supabase recommends one policy per operation rather than a single for all policy, and naming the role each policy applies to.
alter table public.projects enable row level security;
create policy "Owners can read their projects"
on public.projects for select
to authenticated
using ( (select auth.uid()) = owner_id );
create policy "Owners can create projects for themselves"
on public.projects for insert
to authenticated
with check ( (select auth.uid()) = owner_id );
create policy "Owners can edit their projects"
on public.projects for update
to authenticated
using ( (select auth.uid()) = owner_id )
with check ( (select auth.uid()) = owner_id );
create policy "Owners can delete their projects"
on public.projects for delete
to authenticated
using ( (select auth.uid()) = owner_id );
create index if not exists projects_owner_id_idx on public.projects (owner_id);
A few details worth understanding:
usingvswith check.usingfilters which existing rows a user can see or touch.with checkvalidates the row being written. Updates need both, otherwise a user could edit their own row and reassign it to someone else.auth.uid()returns null when logged out. Because a comparison with null is never true, anonymous requests are denied by these policies without extra code.- Wrapping
auth.uid()in(select ...). This lets Postgres evaluate it once per query instead of once per row, which matters on large tables. - The index. Every column used in a policy should be indexed, or RLS will slow your queries down as the table grows.
Grants are the second layer. In existing projects, new tables in public are granted to anon, authenticated and service_role automatically, and policies don't remove those grants. If logged-out visitors should never touch a table, revoke the grant too:
revoke all on public.projects from anon;
Supabase security checklist before you launch
Use this as a minimum before real users or payments touch your app:
- RLS is enabled on every table in exposed schemas, including ones the AI created through SQL.
- Each table has separate policies for select, insert, update and delete, scoped
to authenticated(orto anononly for truly public data). - No policy uses
trueon private data. - Update policies have both
usingandwith check. - Authorization never relies on
raw_user_meta_data. - Unneeded grants to
anonare revoked. - Views use
security_invoker = true, or are not exposed. - Every
security definerfunction has been reviewed, since RLS doesn't apply inside functions. - No secret or
service_rolekey exists anywhere in frontend code, URLs or logs. - Columns used in policies are indexed.
- You have tested with a logged-out session and with a second user account.
What this means if you own an AI-built app
If you built your product with Lovable, Bolt or another AI builder on top of Supabase, the database rules are the part most likely to be wrong and least likely to be visible. The app can look finished, pass every click-through test, and still let a stranger download your customer list.
The good news is that RLS problems are usually quick to fix once someone finds them. The fix is SQL, not a rewrite. The risk is in not knowing which tables are exposed, which policies are too loose, and which parts of the frontend quietly depend on that looseness.
If you'd like a second pair of eyes, a production audit covers RLS policies, key handling, grants and the other common gaps in AI-built apps, with a prioritised list of fixes. And if the audit turns up more than security work, I can take the app the rest of the way through making an AI-built app production-ready.
Sources
- Supabase docs: Row Level Security (opens in a new tab)
- Supabase docs: Understanding API keys (opens in a new tab)
- Supabase docs: Securing your API (opens in a new tab)
- Supabase docs: Securing your data (opens in a new tab)
- CVE-2025-48757 record (CVE Program) (opens in a new tab)
- Cover photo: FlyD on Unsplash

About the author
Muhammad Moiz · Senior Full-Stack Engineer
I'm a senior full-stack engineer with 5+ years building production web apps in React, Next.js and Node.js. I build products end to end for founders and small businesses, and take over apps started with AI tools when they need to grow up.
More about me