The reader I am writing for has done the basic security work. They went into the Supabase dashboard, flipped on Row Level Security for every user table, ran the Lovable scanner, and saw green. Then a friend opened devtools and read every row. This page explains what went wrong between the green check and the open table.
Short answer
Enabling Row Level Security does not, on its own, restrict any access. A policy body of USING (true) evaluates true for every visitor and lets every row through, while still counting as "RLS enabled" in both the Supabase dashboard and Lovable's security scan. According to Supabase's official Row Level Security documentation, the recommended pattern for user-owned rows is USING (auth.uid() IS NOT NULL AND auth.uid() = user_id), which fails closed when the request is unauthenticated.
What you should know
USING (true)is functionally identical to no policy at all for read access. Both grant unconditional access.- Lovable's security scan does not check policy bodies. A green check from the scanner does not mean the table is actually protected.
- Permissive policies combine with OR. If any of the policies on a table returns true, the row is accessible. One permissive policy can defeat several stricter ones.
WITH CHECKis a separate clause. Without it, a user can read their own rows but write the wrong user_id into new rows.- AI-generated policies default to
USING (true)because that is the policy body that makes a test pass without a logged-in user.
What does USING (true) actually do?
A Postgres RLS policy attaches a SQL expression to a table for one or more operations (SELECT, INSERT, UPDATE, DELETE). The Supabase docs describe policies as "implicit where clauses": the expression is added to every query against the table, and only rows where it evaluates true are visible.
With USING (true) the expression is the literal boolean true. Every row matches, regardless of who is asking. From the database's point of view, this is identical to running the query without any policy. The policy exists, which is what the existence check in Lovable's security scan looks for, but it does not filter anything.
The difference between disabled RLS and USING (true) is administrative, not functional. Disabled RLS triggers a warning in the Supabase database advisor. The USING (true) policy passes the advisor entirely. That is why the second failure mode is more common in production: developers see the green check and stop looking.
Why does the AI agent write USING (true) so often?
Three reasons that compound:
First, the AI's tests work. When the agent generates a feature, it tests by running a query against the table from a script or a development browser. That script almost never carries a logged-in JWT, which means auth.uid() returns null. A policy that depends on auth.uid() = user_id fails the test (correctly, from a security perspective). A policy with USING (true) passes the test. The agent picks the policy that passes.
Second, the training data is biased. Public Supabase examples on GitHub and Stack Overflow over-represent the simplest possible policy body to focus the example on something else. The AI sees USING (true) more often than it sees the defensive auth.uid() IS NOT NULL AND auth.uid() = user_id pattern, and it weights its output accordingly.
Third, Lovable's own scaffolding does not warn when a new table is created without a per-user filter. The agent fills the gap with whichever policy makes the feature run end-to-end. That is USING (true) more often than it should be.
How do you find every USING (true) policy in your project?
Four checks, in order of reliability:
- In the Supabase dashboard, go to Authentication, then Policies. Each row shows the policy body. Sort or filter for any row containing
truein the USING column. - Run this SQL query in the Supabase SQL editor:
SELECT schemaname, tablename, policyname, qual, with_check FROM pg_policies WHERE qual = 'true' OR with_check = 'true';. Thequalcolumn is the USING clause;with_checkis the WITH CHECK clause. Any row returned is a permissive policy. - From a logged-out browser session, copy the anon_key from your bundle and try to SELECT from each table. Tables that return rows are accessible to unauthenticated traffic.
- Use the Supabase database advisor under Database, Advisors. The
0006_multiple_permissive_policieslint warns when overlapping permissive policies create an effectively open table.
For mobile apps that compile a Lovable backend into the binary, PTKD.com (https://ptkd.com) parses the Supabase endpoints out of an APK or IPA and tests each one against an unauthenticated query, surfacing the tables where a USING (true) policy is the only thing standing between the attacker and the data.
What should the policy actually say?
The correct policy body depends on what the table holds and who is allowed to see what. The three patterns that cover most schemas:
| Access shape | USING clause | WITH CHECK clause | Notes |
|---|---|---|---|
| User-owned rows (todos, files, notes) | auth.uid() IS NOT NULL AND auth.uid() = user_id | auth.uid() = user_id | The defensive IS NOT NULL makes the policy fail closed for unauthenticated traffic. |
| Multi-tenant rows (org-scoped data) | org_id IN (SELECT org_id FROM memberships WHERE user_id = auth.uid()) | org_id IN (SELECT org_id FROM memberships WHERE user_id = auth.uid()) | The membership table needs its own RLS policy or the query leaks org membership. |
| Public read, authenticated write (blog posts, catalog) | USING (true) for SELECT only | auth.uid() IS NOT NULL for INSERT or UPDATE | Public read is intentional. Writes still require a session. |
The trap in the third row is leaving the public-read body on tables that should not be public. The check is whether the table contains anything that should not appear in a search engine result. If yes, the policy belongs in row one or two.
Why does Supabase recommend the explicit auth.uid() IS NOT NULL pattern?
The Supabase documentation explicitly suggests adding auth.uid() IS NOT NULL even when the equality check already implies it: USING (auth.uid() IS NOT NULL AND auth.uid() = user_id). The reason is how Postgres evaluates null comparisons.
For an unauthenticated request, auth.uid() returns null. The expression null = user_id evaluates to null, not false, and Postgres treats a null policy result as "deny." In practice the short form USING (auth.uid() = user_id) does the right thing. But policies are debugged in the dashboard, where the explicit form is unambiguous and survives later edits that might invert the logic. The defensive pattern fails closed in every reading and is the form Supabase tutors recommend.
The related danger is when the policy body uses an OR. A policy like USING (auth.uid() = user_id OR is_public) is meant to allow either the owner or anyone if the row is marked public. If is_public is a column the user controls, the policy is effectively USING (true) for any row the user sets is_public = true on. Review every OR for this pattern.
What to watch out for
Three details that show up in audits of fixed Lovable projects.
First, fixing the policies does not retroactively unbreach data. If your Supabase logs show queries against the affected tables from unexpected IPs in the weeks before the fix, treat the affected records as compromised and follow your standard notification flow. Supabase retains query logs for seven days on the free tier and longer on paid plans.
Second, the AI agent will sometimes regenerate the file that originally created the table. The new migration can drop and recreate the table without carrying forward the policies you wrote by hand. Add the policies to your schema migration script, not just to the live database, so the next AI-generated migration includes them in source.
Third, the membership-join pattern for multi-tenant policies has its own trap. If the memberships table itself has no RLS or an open policy, the join leaks who is in which organisation. Always write a self-referencing policy on the membership table: USING (user_id = auth.uid()) lets each user see only their own memberships.
Key takeaways
- Enabling RLS without writing a real policy body is not protection.
USING (true)is the policy AI agents most often pick. - Lovable's security scan checks existence, not effectiveness. The Supabase database advisor and a direct SQL query against
pg_policiesare the real audit tools. - The defensive pattern is
USING (auth.uid() IS NOT NULL AND auth.uid() = user_id)with a matchingWITH CHECKfor writes. TheIS NOT NULLis a deliberate belt and braces. - For Lovable apps compiled to mobile, scanning the APK or IPA with PTKD.com (https://ptkd.com) before submission catches the same permissive-policy patterns Apple and Google reviewers eventually flag.
- Document the policy review in your CHANGELOG so the next AI-generated migration cannot quietly revert it.




