TL;DR
- When a Supabase insert fails with "new row violates row-level security policy", AI editors usually "fix" it with a
using (true)/with check (true)policy or by disabling RLS. - That makes the error go away because it makes the table readable or writable by anyone holding your anon key, which ships in your frontend bundle.
- The real fix is four owner-scoped policies built on
auth.uid(), plus a 60-second curl test with the anon key.
I hit this on a small notes app last week. The insert from the browser failed with new row violates row-level security policy for table "notes". I pasted the error into Cursor and asked it to fix it.
It did. The error disappeared, the app worked, and I almost moved on. Then I read the migration it wrote.
The "fix" was a policy that lets anyone, signed in or not, read and write every row in the table. The app worked because nothing was being checked anymore.
The Vulnerable Code
The fix an AI editor typically writes for an RLS error is an always-true policy, or turning RLS off entirely. Both silence the error by removing the access control that caused it.
-- What the AI wrote to "fix" the insert error (CWE-863, Incorrect Authorization)
create policy "Enable insert for all users"
on public.notes for insert
with check (true);
create policy "Enable read access for all users"
on public.notes for select
using (true);
-- Or, when that did not work on the first try:
alter table public.notes disable row level security;
Here is why that matters. Your Supabase anon (publishable) key is meant to be public. It sits in your JavaScript bundle, and anyone can copy it out of devtools. Row Level Security is the only thing standing between that key and your data. A using (true) policy tells Postgres to let every row through. With RLS disabled, any role with a grant on the table can read and write all of it through the auto-generated REST API.
The second "fix" I see a lot is worse. When the policy route gets confusing, the AI moves the insert to a client that uses the service_role key and exposes it as NEXT_PUBLIC_SUPABASE_SERVICE_ROLE_KEY. Supabase's own docs say that key goes through the service_role role, which has the bypassrls attribute. In a public env var, it is your whole database, readable from the browser.
Why This Keeps Happening
AI editors optimize for making the error go away, and an always-true policy is the shortest diff that does it. The model sees "violates row-level security policy", pattern-matches it to "the policy is too strict", and loosens it.
There is also a lot of using (true) in its training data. Quickstarts and tutorials use it for public lookup tables, and policy names like "Enable read access for all users" appear constantly. For a table of country codes, that is fine. For a table of user notes, invoices or chat messages, it is a data leak.
This is not hypothetical. CVE-2025-48757, published May 29, 2025, describes insufficient RLS policies in apps generated by Lovable that let remote unauthenticated attackers read or write database tables. Matt Palmer, who reported it, lists exposed names, emails, third-party API keys and payment status data in his write-up. Lovable disputes the CVE, saying each customer is responsible for protecting their app's data. That dispute is the point. Whoever is responsible, the policy is what decides, and the AI wrote the policy.
The Fix
Keep RLS on and write one policy per operation that ties each row to the signed-in user with auth.uid(). The insert error almost always means the row you sent does not have a user_id matching the caller, so fix the data, not the policy.
alter table public.notes enable row level security;
-- Let Postgres fill in the owner so the client cannot forget or fake it
alter table public.notes alter column user_id set default auth.uid();
create policy "notes_select_own" on public.notes
for select to authenticated
using ((select auth.uid()) = user_id);
create policy "notes_insert_own" on public.notes
for insert to authenticated
with check ((select auth.uid()) = user_id);
create policy "notes_update_own" on public.notes
for update to authenticated
using ((select auth.uid()) = user_id)
with check ((select auth.uid()) = user_id);
create policy "notes_delete_own" on public.notes
for delete to authenticated
using ((select auth.uid()) = user_id);
A few details that matter:
-
usingdecides which existing rows a user can see or change.with checkdecides what a new or updated row is allowed to look like. Update needs both, or a user can move their row to someone else'suser_id. -
to authenticatedkeeps signed-out visitors out entirely. Supabase's docs also point out thatauth.uid()is null for anonymous requests, so the comparison quietly fails for them rather than erroring. - Wrapping
auth.uid()in(select ...)lets Postgres evaluate it once per statement instead of once per row. Supabase recommends it for performance.
On the client, insert without sending user_id at all:
// The default fills user_id from the caller's JWT
const { error } = await supabase.from('notes').insert({ body: text });
If something genuinely needs elevated access, like an admin action or a webhook, do it in a server route or Edge Function that verifies the caller first, and read the service_role key from a server-only env var with no NEXT_PUBLIC_ or VITE_ prefix.
How to Check Your Own App
Use the same key an attacker would use: your anon key, with no user session.
curl "https://YOUR_PROJECT_REF.supabase.co/rest/v1/notes?select=*" \
-H "apikey: $SUPABASE_ANON_KEY" \
-H "Authorization: Bearer $SUPABASE_ANON_KEY"
If that returns rows, anyone on the internet can get the same rows. With the policies above, it returns []. Repeat it for every table that holds user data, and grep your migrations for using (true), with check (true) and disable row level security.
FAQ
Q: Is it safe to expose the Supabase anon key in my frontend?
A: The anon key is designed to be public, but it is only safe when every table it can reach has RLS enabled with policies that scope rows to the caller. Without that, the anon key is a read and write key for your data.
Q: How do I fix "new row violates row-level security policy" without disabling RLS?
A: Make sure the inserted row's user_id equals auth.uid(), ideally by setting the column default to auth.uid(), and add an insert policy with with check ((select auth.uid()) = user_id) for the authenticated role.
Q: When is USING (true) acceptable?
A: Only for SELECT on tables that are genuinely public, like a list of countries or published blog posts. Never for INSERT, UPDATE or DELETE, and never on a table with user data.
I've been running SafeWeave for this. Its repo checks flag RLS being disabled (supabase-rls-disabled), always-true policies (supabase-rls-passthrough-policy) and a service_role key in a NEXT_PUBLIC_ or VITE_ variable, and its read-only live check runs the same anon-key test against your deployed app to find tables anyone can read. Those checks are on the Cloud plan; the free tier shows the counts. Even without a scanner, the curl test above and a grep for using (true) catch most of this. The important thing is catching it before your users' data is the thing that proves it.
Top comments (1)
Official Platform Update
Security protocols have been updated for all developer accounts.