RLS is on. Your Supabase data is still public.
Why AI-built and human-built Supabase apps ship Row-Level Security that is enabled but leaks, how to detect it with two test accounts, and safe, tested policies.
In a Supabase app the public key is meant to be public; Row-Level Security is what protects the data. The failure I see most is not RLS switched off but RLS switched on with policies that only check that someone is logged in. This article reproduces it on a local test database, shows how to detect it without touching anyone else's app, and gives policies I tested, plus the pitfalls that surprised me.
1. The problem
In a Supabase app, the browser talks to the database directly. The key it uses, the "anon" or "publishable" key, is meant to be public: it ships in every visitor's JavaScript. What protects the data is Row-Level Security (RLS): Postgres policies that decide, row by row, who can read or change what.
When I review apps, the problem I run into most is not RLS being switched off. It is RLS being on, with policies that don't say what the developer thinks they say. I have seen this about ten times in client reviews. I can't name those clients (the reviews are under NDA), and ten cases is what I saw, not a measurement of how common it is.
The code behind those policies was written by AI coding tools (Lovable, Codex, Claude Code) as often as by people. My hypothesis, which I haven't tested rigorously, is that the failure mode is the same either way:
- The goal is a working screen. A policy that blocks a query produces a visible error; a policy that is too permissive produces nothing visible.
- The UI hides the problem. The frontend filters by the current user, so clicking around looks correct. The leak only shows when you call the API directly, as another user.
- "Secure" is a property of the whole system. A policy can look reasonable in isolation and still be wrong for the product.
In 2025, Matt Palmer scanned 1,645 projects built with Lovable and found 303 endpoints across 170 of them (about 10.3%) with inadequate RLS, exposing data that "includes subscriptions, names, phone numbers, API keys, payment details" (his statement). It became CVE-2025-48757, rated 9.3 by its CNA and marked disputed: Lovable's position is that each customer is responsible for protecting their application's data.
That dispute is the interesting part. The tool generated the policy, the customer shipped it, and each side can say the other was responsible. The data was exposed either way.
Every example here ran on a local test database I created. Do not test an application you don't own or don't have written permission to test: unauthorized access can be illegal (e.g. art. 323-1 of the French Penal Code, the CFAA in the US).
2. A reproducible example
I reproduced the pattern on a local Postgres 16 database with a minimal imitation of Supabase's auth.uid() and auth.role() helpers and its anon / authenticated roles. It is an imitation, not a real Supabase project; the policy behavior is plain Postgres.
One table, two users, A and B, each with one private note. Logged in as B, selecting from notes returns both notes: the policy checks that the caller is logged in, not that they own the row. Any free account can read every user's data, and nothing in the UI shows it.
create table notes (id serial primary key, owner_id uuid not null, body text);
alter table notes enable row level security;
-- The policy I see most: RLS is on, and it "requires login".
create policy "logged in can read" on notes
for select using (auth.role() = 'authenticated');3. How to detect it (read-only)
- Without an account. Check which tables answer an anonymous request. I wrote an open-source tool for this, validra-check:
npx validra-check https://your-app.com. It only sends GET requests and never prints row values. It would not catch the example above, because that leak needs an account. - Read the policies. List tables where RLS is off (
select tablename from pg_tables where schemaname = 'public' and not rowsecurity;) and read every policy inpg_policies. Look fortrue, for policies that only testauth.role(), and for INSERT policies without an ownership check. - Two test accounts. Log in as B through the client SDK or the REST API and try to read and write A's rows. This is the only step that finds the example above. Don't run it in the SQL editor: it runs as
postgres, which bypasses RLS.
In the client reviews, step 3 is where the real issues showed up: testing directly against the project's API, with the owner's authorization, then recommending safer policies like the ones below.
4. The fix
These policies follow the Supabase RLS documentation. I ran each one against the test database: B sees only B's note, cannot create a note owned by A, cannot hand its note over to A, and an anonymous request gets nothing.
-- Read: only your own rows.
-- "to authenticated" stops anonymous requests before the check runs.
-- "(select auth.uid())" is evaluated once per query, not once per row.
create policy "owners read their notes" on notes
for select to authenticated
using ((select auth.uid()) = owner_id);
-- Insert: the new row must belong to you.
create policy "owners create their notes" on notes
for insert to authenticated
with check ((select auth.uid()) = owner_id);
-- Update: USING picks which rows you may change, WITH CHECK validates the result.
create policy "owners update their notes" on notes
for update to authenticated
using ((select auth.uid()) = owner_id)
with check ((select auth.uid()) = owner_id);
create index on notes (owner_id); -- policies filter on it5. Pitfalls I checked
- `INSERT ... with check (true)`: tested, B can create rows owned by A. Always check ownership on insert.
- `UPDATE` without `WITH CHECK`: I expected it to let B reassign its rows to A. It doesn't: Postgres reuses the
USINGexpression as the check. Write it explicitly anyway, so the intent is obvious to whoever edits the policy next, human or model. - RLS on, no policy: tested, nothing is visible. The tempting fix for that error is
using (true), which opens everything. - Views: tested, a plain view created by
postgresignores the table's RLS.security_invoker = true(Postgres 15+) fixes it. - Authorization from `user_metadata`: Supabase warns against it, because users can edit their own metadata.
- `SECURITY DEFINER` functions and the `service_role` key bypass RLS by design. Keep them server-side and review every one.
- Storage has its own policies, separate from tables.
6. Checklist
- RLS enabled on every table in an exposed schema
- No
using (true)orwith check (true)on tables with user data - Every policy names its role (
to authenticated) - Policies compare the row's owner to
(select auth.uid()), not just "is logged in" - INSERT and UPDATE policies have an explicit
WITH CHECK - No authorization based on
user_metadata - Views use
security_invoker = true - Every
SECURITY DEFINERfunction reviewed service_roleand secret keys never in client code- A two-account isolation test runs in CI
- Storage policies reviewed
7. What a scanner can't see
An outside-in scanner, including mine, only sees what an anonymous visitor sees. It cannot tell whether user A can read user B's data, whether one team can read another team's invoices, or whether a technically correct policy is too broad for your product. Those questions need someone, or something, that knows how the product is supposed to work.
8. What I don't know yet
- How common this is. About ten cases from my own reviews and one large public scan. That is not a rate.
- How much is caused by AI tools. The cases came from AI-generated and human-written code alike; I can't separate the two from what I observed.
- Whether current coding agents still do it. The next experiment is to ask several coding agents to build the same multi-user app and measure how often their default policies leak across accounts. I haven't run it yet.
- How to automate the two-account test safely. My tool deliberately stops at anonymous access. Testing authenticated isolation automatically, without the tool becoming an attack tool, is an open design question for me.
Frequently asked questions
Is the Supabase anon key a secret?
No. It is designed to be public and ships in every visitor's browser. Row-Level Security policies are what protect the data; the service_role or secret key is the one that must never reach the client.
Will an automated scanner find a misconfigured policy?
Only if the data is readable without logging in. A policy that lets any logged-in user read everyone's rows needs a two-account test to be found.
Does an UPDATE policy need WITH CHECK?
If you omit it, Postgres reuses the USING expression as the check, so ownership is still enforced. Writing it explicitly makes the intent clear and avoids mistakes when the policy is edited later.
Is your SaaS ready for production?
Identify critical vulnerabilities before your users do with the Validra Security Review in 5 business days.