new row violates row-level security policy means Postgres refused a row your app tried to write: RLS is on for that table, and no policy allows that row for the role sending the request. Supabase returns it as error 42501, or as a 403 on a Storage upload. Five causes produce it; switching RLS off only hides it.
What new row violates row-level security policy means, and why Supabase returns 42501
The message new row violates row-level security policy is Postgres refusing a write: the row failed a policy’s check, or no policy covers that command for the calling role. It carries error code 42501, which a missing table grant also uses, so the words of the message decide which one you have.
Postgres applies the rule, not Supabase. Once row security is enabled on a table, every normal read or write must be allowed by a policy, and if no policy exists for the table, Postgres falls back to default deny: no rows are visible and none can be modified. Policies are written per command (SELECT, INSERT, UPDATE or DELETE) and per role, and when the new row’s with check expression returns false or null, the whole statement fails with this error. PostgreSQL’s row security chapter sets out the model, and what a Supabase RLS policy can decide covers its limits for an app.
Code 42501 is Postgres’s insufficient_privilege condition, and it covers more than policies. Supabase’s 42501 troubleshooting guide lists five sources in this order: a forbidden schema such as auth, a custom schema the Data API was never given, a missing table-level privilege, a column-level restriction, and RLS refusing an INSERT or UPDATE from the anon or authenticated role. The message text separates the two you will meet most. A missing grant reads “permission denied for table”, and Supabase’s RLS guide says that one fires before any policy runs. A policy refusing the row reads “new row violates row-level security policy”. A read that a policy filters is a third case: it raises nothing and simply returns fewer rows, often none.
When the message reads new row violates row-level security policy for table followed by your table’s name, the name tells you where to look, not which cause you have. Two names do change the cause, and each has its own section below: profiles straight after sign-up, and objects (the storage.objects table) on a file upload.
Find your cause: five causes, one symptom table
The error has five causes in Supabase: no INSERT policy, a request that arrives as anon, an owner column the check rejects, a row read back with no SELECT policy, and Storage policies on storage.objects. Where the write fails tells you which one.
| Where it fails | Likely cause | First check | Section |
|---|---|---|---|
| Every insert from the app fails, while the same statement works in the SQL editor | RLS is on and no INSERT policy covers the app’s role; the editor runs as postgres, which bypasses RLS | List the table’s policies with the pg_policies query further down | Cause 1 |
| Fails for logged-out users, or right after sign-up | The request reached Postgres as anon, so auth.uid() is null | Confirm the request carries the signed-in user’s access token, not only the publishable key | Cause 2 |
| Fails for some rows only | The owner column is empty or holds another user’s id, so with check is false | Compare the row you send with the signed-in user’s id | Cause 3 |
The plain insert works; .insert().select() or .upsert() fails | Reading the row back needs a SELECT policy; an upsert also needs UPDATE | Run the insert once without .select(), as a test only | Cause 4 |
| A file upload fails with a 403 and the same message | Policies on storage.objects: INSERT, plus a SELECT that mirrors it | Open the bucket’s policies | Storage |
Two checks need no SQL at all. The Security Advisor in the Supabase dashboard flags tables with RLS disabled in public schemas, RLS enabled with no policies, and policies written as USING (true). In a Lovable app, row level security policies control which users can access or modify the data, and every policy in the project can be reviewed under More, Cloud, Database, RLS policies.
Before you fix it: the three shortcuts that hide the error and open the table
The three shortcuts that make the error disappear also leave the data open: switching RLS off on the table, a policy whose check is true, and a service_role key in browser code. Each turns a refused write into a write made by callers you never meant to allow, which can be anyone who loads the app.
Switching RLS off removes the refusal and the protection together. Supabase’s docs say a table with RLS disabled is not automatically reachable unless the role also has the necessary privilege and the object is in an exposed schema. The catch is the grants: on existing projects, tables created in public receive SELECT, INSERT, UPDATE and DELETE for anon and authenticated by default, and Supabase says these grants make new objects reachable through the Data API, even when you don’t intend to expose them. Supabase also says it is changing the platform default to revoke these automatic grants, so that exposure becomes opt-in.
A policy whose check is true lets every caller of its role insert any row under any user’s id. One app from our June and July 2026 audits was a Q&A product whose newer migration removed its “authenticated users can create questions” policy and added a replacement INSERT policy that checks nothing. From then on, a signed-out visitor could publish questions in any real user’s name: the post took that user’s id and appeared on the public feed with their name and badge.
Putting a service_role or secret key in browser code makes the error vanish, because the key skips the policies; Supabase notes that a secret key bypasses RLS only when the request carries no user access token. Supabase says these keys “provide full access to your project’s data, bypassing Row Level Security”, and lists “a browser, even on localhost” among the places a secret key must never appear.
Row-level security gaps turned up in 9 of the 21 third-party apps we audited in June and July 2026. Those apps were picked for review rather than drawn at random, so the count describes that group and says nothing about the share of Supabase apps in general. Whether a table is already readable by strangers takes a couple of minutes to see with the two-minute Supabase exposure test.
If the app already holds customers’ data, launch is close, and your AI tool keeps proposing one of these three to make the error go away, bring the question to a free audit: we talk through with you which tables the browser writes to and which of them still need a rule. In the sprint, deliverable 1.4 implements and tests row-level security or equivalent server-side scoping across all tables, with explicit rules for intentionally public data.
Cause 1: RLS is on and the table has no INSERT policy
With RLS on and no INSERT policy for the role, every insert from the app is refused, because policies are per command and a SELECT policy does not cover INSERT. The same statement works in the SQL editor because queries run in the Supabase dashboard execute as postgres, which has bypassrls, and roles with that attribute always bypass row security. A passing test in the editor proves nothing about the app.
Tables created in the Supabase dashboard start with RLS enabled; tables created in the SQL editor or another tool need it enabled explicitly. The switch is on the Database > Policies page in the dashboard. Then add the INSERT policy. Here is a row-level security example for a table whose rows belong to one user, in the form Supabase’s row-level security guide prints it:
create policy "Users can create their own rows"
on public.your_table for insert
to authenticated
with check ( (select auth.uid()) = user_id );
The with check expression is what stops a user creating a row that belongs to someone else. It fits a table whose rows each belong to one user; a team or workspace table needs a membership check instead (Cause 3). Supabase recommends wrapping auth.uid() in select so Postgres runs it once per statement, and why the select wrapper speeds up policies goes into the numbers. Supabase also says to write a separate policy for each of select, insert, update and delete rather than one policy for all.
Cause 2: the request runs as anon, so auth.uid() is null
A policy written to authenticated never runs for a request that arrives as anon: Supabase says execution stops at the to authenticated step. The API uses anon whenever a user is not signed in, and with no access token, or an expired session, auth.uid() returns null. A check that compares null with user_id is never true, so the row is refused even though the user sees themselves as logged in.
In our reading, a signed-in user’s request can turn into anon in three places: the Supabase client was created before sign-in and never picked up the session, server code calls Supabase with only the publishable key and no user token, or a server-rendered page does not pass the auth cookie on to the client it builds. To check, open the failing request in the browser’s network tab. Supabase sends the publishable key on the apikey header, so that header alone proves nothing; the user’s access token, a JWT, has to travel on the request as well. Never fix this by adding anon to the policy: that hands the write to every visitor.
new row violates row-level security policy for table profiles, right after sign-up
The app calls sign-up, then inserts the profile row from the browser, and the insert is refused. With Confirm email enabled, which is the default, signUp returns a user but the session is null, so that insert runs as anon and fails the to authenticated policy. The same thing happens to a users table an app keeps beside auth.users, which is where new row violates row-level security policy for table users comes from.
The fix Supabase documents moves the insert to the database. A trigger on auth.users writes the profile row whenever a user is created, so the client never inserts into profiles at all. The function and trigger, as Supabase’s guide to managing user data prints them:
create function public.handle_new_user()
returns trigger
language plpgsql
security definer set search_path = ''
as $$
begin
insert into public.profiles (id, first_name, last_name)
values (new.id, new.raw_user_meta_data ->> 'first_name', new.raw_user_meta_data ->> 'last_name');
return new;
end;
$$;
create trigger on_auth_user_created
after insert on auth.users
for each row execute procedure public.handle_new_user();
Supabase warns that a failing trigger can block sign-ups, so test it before launch. Leave the profiles INSERT policy as it is; loosening it to anon lets anyone create profile rows.
Cause 3: the row you sent fails the WITH CHECK expression
On an INSERT, Postgres tests only the with check expression, against the new row; an INSERT policy cannot even have a using clause. On an UPDATE, the using clause decides which existing rows can be updated and the with check clause decides what the resulting row is allowed to look like. The CREATE POLICY reference sets out which expression each command meets:
| Command | USING applies to | WITH CHECK applies to |
|---|---|---|
| SELECT | Existing rows (filtered out silently) | Not used |
| INSERT | Not allowed on an INSERT policy | The new row (refused with an error) |
| UPDATE | The existing rows the update can reach | The resulting row (refused with an error) |
| DELETE | The existing rows the delete can reach | Not used |
The usual failure is the owner column. The app never sets user_id, or sets it from stale state to another user’s id, so the check compares auth.uid() with null or with the wrong person. A column default of auth.uid() on user_id means the app no longer has to send the owner at all, and the policy then checks a value the database filled in; that is our practice, not a line from Supabase’s docs. For a team or workspace table the check is membership rather than user_id, and the multi-tenant membership policy shows how to write it.
infinite recursion detected in policy for relation
A membership check can raise a different error. In Supabase, the infinite recursion detected in policy for relation error appears when two tables’ policies read each other: a policy on lists looks up list_members, and the policy on list_members looks back at lists. Postgres raises it as code 42P17, and the query fails for every role those policies apply to. The fix Supabase documents is a security definer function that reads the membership table for the policy, since security definer functions avoid evaluating another table’s RLS inside a policy. Supabase puts that function in a private schema with set search_path = '', revokes its execute right from public, and warns never to create one in a schema listed under “Exposed schemas” in the API settings.
Cause 4: the insert works, but reading the row back fails
In supabase-js v2, an insert does not return the new row unless .select() is chained onto it. With .select(), the write must also hand the new row back, and Postgres’s rule for a RETURNING clause applies: the returned row has to satisfy the table’s SELECT policies too; if it does not, Postgres throws an error rather than quietly returning nothing. So a table with an INSERT policy and no SELECT policy for that role fails with this same message on .insert().select(), while the bare insert goes through. An INSERT with RETURNING needs a SELECT policy.
.upsert() adds a second requirement. When the row already exists, and ignoreDuplicates is not set, it runs an update, and Postgres checks the existing row against the UPDATE policy’s using and the new row against its with check; an on conflict do update also checks the proposed row against the SELECT policies, whether or not you chain .select(). The supabase-js insert reference shows both calls:
// Insert and read back: needs INSERT and SELECT policies
const { data, error } = await supabase.from('your_table').insert({ title: 'Draft' }).select();
// Upsert: also needs an UPDATE policy for the row it replaces
await supabase.from('your_table').upsert({ id: rowId, title: 'Edited' }).select();
The fix is the matching SELECT policy (and UPDATE policy, for upserts) for the same role, or not reading the row back when that caller should not read it. A fix still copied from old threads, { returning: 'minimal' }, belongs to supabase-js v1: the v2 upgrade guide says the default now is to not return rows, and that you add .select() to get them.
Supabase Storage row level security: why uploads fail with a 403
Supabase Storage row level security lives on the storage.objects table, and an upload can need more than an INSERT policy: the upload reads its row back, so a missing matching SELECT policy fails it with a 403, and an upload with upsert on also needs SELECT and UPDATE.
By default, Storage does not allow any uploads to buckets without RLS policies, and access is opened by creating policies on the storage.objects table, with folder paths read by helpers such as (storage.foldername(name))[1]. Supabase’s access control page names INSERT as the only policy an upload requires, and says overwriting with upsert needs SELECT and UPDATE as well.
On a Storage upload, the same new row violates row-level security policy message arrives inside a 403 Forbidden response. Supabase’s Storage 403 troubleshooting page explains why it can appear even when the INSERT policy and the user’s JWT are right: the Storage API runs an INSERT followed by RETURNING *, so a missing or too-narrow SELECT policy on storage.objects fails the whole upload, and the documented fix is a SELECT policy mirroring the INSERT requirements. Here is that pair for a bucket where each user uploads into a folder named after their id, based on the examples in Supabase Storage access control:
create policy "Users upload to their own folder"
on storage.objects for insert to authenticated
with check ( bucket_id = 'your_bucket' and (storage.foldername(name))[1] = (select auth.jwt()->>'sub') );
create policy "Users read their own folder"
on storage.objects for select to authenticated
using ( bucket_id = 'your_bucket' and (storage.foldername(name))[1] = (select auth.jwt()->>'sub') );
Bucket privacy, signed links and upload limits sit outside this error; the object storage security checklist covers them.
How to add RLS policies to every table, and test them as an anonymous user
RLS policies on every table are proven by requests, not by reading the SQL: one write with no session, one as the owner, one carrying another user’s id, one read as the owner and one upload into another user’s folder, each with the result kept.
Start with two lists. Run both queries in the SQL editor, as a role with access to every table: the first returns the tables in public with row security off, from the rowsecurity column of pg_tables; the second returns every policy with its command, roles and expressions, from pg_policies.
select tablename from pg_tables
where schemaname = 'public' and rowsecurity = false;
select tablename, policyname, cmd, roles, qual, with_check
from pg_policies where schemaname = 'public'
order by tablename, cmd;
That is how to add RLS policies to every table without guessing: a table in the first list, or a table the app writes to with no INSERT or ALL row in the second, is the next fix, and the to-do list is one policy per command per role. Then prove the result with five requests from the app’s own client, using two test accounts, A and B. The first tests database access as an anonymous user, the way a stranger’s browser would.
- 01 With no session, insert a row. Expect a refusal: this error, or "permission denied for table" if anon holds no grant on the table.
- 02 Signed in as A, insert a row owned by A. Expect success, and the row back when you chain a select.
- 03 Signed in as A, insert a row carrying B's id. Expect this error.
- 04 Signed in as A, select from the table. Expect A's rows only; B's rows are simply absent, with no error.
- 05 Signed in as A, upload a file into B's Storage folder. Expect the 403 with the same message.
Keep each response with its date; that record is the evidence the rules work. A full Supabase RLS test suite takes the same idea into pgTAP and CI.
These five requests follow the check the sprint runs for deliverable 1.4, which is verified this way: run read and write tests as anonymous users, different roles, and separate tenants.
RLS is one row of the authentication checklist for AI-built apps, and keeping every customer’s records apart in a multi-tenant app is the wider job of data isolation across every table.
Common questions about the Supabase RLS insert error
Is row level security necessary?
Yes, for every table the browser can reach through Supabase’s Data API. A table can be reached there only when an API role holds a grant on it, and on existing Supabase projects, tables created in public get SELECT, INSERT, UPDATE and DELETE grants for anon and authenticated by default, so without RLS any of those roles can read and change every row. A table is out of the browser’s reach only when it sits outside the exposed schemas or the API roles hold no grant on it, never just because only server code uses it today.
Does PostgreSQL have row level security?
Yes. Row security policies are a PostgreSQL feature, also known as Row-Level Security, enabled per table with ALTER TABLE ... ENABLE ROW LEVEL SECURITY and written with CREATE POLICY. Supabase runs on Postgres, so the refusal behind this error comes from the database itself, and the same policy rules apply on any Postgres database.
How to use row level security?
Enable it on each table, then write one policy per command and role: using for the existing rows a command can see or change, and with check for the rows it may write. After that, test as two different accounts and as a signed-out visitor, and keep each result, because a policy that reads correctly can still let the wrong request through.
When should I get help with Supabase RLS errors?
Get help when the same error returns on every new table, or when an AI tool’s answer to it is to loosen a policy, because both are signs the access rules were never designed table by table, and that is worth a second look before real users arrive. AxonBuild offers a free audit, where we weigh up with you whether the policies you have match who should see and change each kind of data.
Inserts blocked by a Supabase security policy?
The quick way out of this error is to switch the rule off, and that often leaves other customers' data open. Bring it to an audit: we go over how your app decides who can write which rows, and point out where the rules are thin. You get a clear answer on what to fix first without opening the data up.
Book a free audit →
Free · 30 minutes · No code access needed