Supabase throws the same six words no matter which of six different problems caused them: new row violates row-level security policy. That’s Postgres error 42501, a plain permission denial, and the least helpful part of the message is that it’s specific about the failure and silent about the cause. So the fix that spreads fastest on forums is the one that requires no diagnosis at all: turn row-level security off. That fix works, in the sense that the error goes away. It also means anyone holding your public API key can write to the table you just unlocked.

The fix that clears this most of the time

If you enabled RLS on a table and never wrote a policy for it, every insert from your app fails: anon key, authenticated session, any client that isn’t using the service-role key or connecting as the table owner. Enabling RLS switches a table’s default from open to deny-all: no policy means no access, full stop, for every role except the ones you explicitly grant. The fix is one policy with a with check clause that ties the row to the person inserting it.

create policy "Users can insert their own rows"
on orders
for insert
to authenticated
with check ( (select auth.uid()) = user_id );

That single policy, checked against Supabase’s current row-level security guide, is the majority fix: it names the role allowed to insert (authenticated, not anon), and it checks that the user_id on the new row matches the caller’s own id rather than checking nothing at all. If your error clears here, the rest of this post is reference material for when it doesn’t.

What error 42501 means

42501 is Postgres’s standard insufficient_privilege code, and Supabase’s row-level security engine raises it the same way a missing table grant would: the operation was refused, not malformed. Seeing this error is actually a sign RLS is doing its job. The unhelpful part is that six unrelated situations all produce the identical message, and the six of them need different fixes.

SymptomLikely causeFix
Every insert fails, from every client, including the dashboardRLS is on and no policy exists for this table at allAdd an INSERT policy with a real with check
Insert fails, but the same request works with the service role keyThe policy targets authenticated and the request is running unauthenticated (or the reverse)Match the policy’s to role to who’s actually calling
Insert fails right after login, or only for logged-out usersauth.uid() is returning null because no session reached PostgresConfirm the JWT is attached to the request; guard with is not null
Insert fails on one column’s value but the row looks right otherwisewith check compares against a column that isn’t the real owner idPoint the check at the column that actually holds the caller’s id
The insert succeeds, then the client still throws this errorNo SELECT policy covers the row your client asked to get backAdd a matching SELECT policy, or stop asking for the row back
Upload to Supabase Storage fails with the same messagestorage.objects has its own, separate RLS policies, and by default it has noneWrite an INSERT policy on storage.objects, scoped to the bucket

USING vs WITH CHECK, and why an insert only checks one of them

Supabase’s own documentation draws the line cleanly: using decides which existing rows a policy lets you see or touch, and applies to select, update, and delete. with check validates the row you’re about to write, and applies to insert and update. An insert has no existing row to filter, so its policy only ever needs with check. Update needs both: using to confirm you can touch the row that’s there, with check to confirm the row still qualifies after your change.

The fourth diagnostic-table row, the one where the row “looks right” but still gets rejected, is almost always this clause pointed at the wrong column. A generator will happily write with check (true), which passes everything, or with check ((select auth.uid()) = id) when the table’s real ownership column is user_id, not id. Postgres evaluates the expression exactly as written; it has no idea what you meant the column to be.

Wrong role, or auth.uid() returns null

Two of the six causes look identical from the client and come from opposite directions. The first is a policy written for the wrong role: to authenticated on a table your app actually writes to with the anonymous key, or a policy built assuming service_role and then called from browser code that never had that key. Supabase policies are role-scoped on purpose, and a policy that never matches the calling role behaves exactly like no policy at all.

The second is a null identity reaching a policy that assumed there’d always be one. Supabase’s documentation states it directly: when a request carries no authenticated user, auth.uid() returns null, and null = user_id is never true in SQL, for any value of user_id. An expired session, a request that never attached its access token, a server-side call still running on the anonymous key: all three produce the same silent no-match. The documented fix is to check for the null explicitly rather than let it fail the comparison quietly:

with check ( (select auth.uid()) is not null and (select auth.uid()) = user_id )

That extra clause doesn’t change what the policy allows. It changes what a future reader of the error sees: a null check that fails is one line, an equality on null is invisible.

The insert works, and the client still throws this error

The gotcha that catches people who already wrote a correct INSERT policy: the row goes in, and the client still reports new row violates row-level security policy. Supabase’s JavaScript client doesn’t return the inserted row by default; you get it back only if you chain .select(). That chain changes the request itself: it tells PostgREST to answer with the row (return=representation instead of the default return=minimal), and returning a row means reading it back, which means Postgres checks it against your SELECT policy, not your INSERT policy. An app with an INSERT policy and no matching SELECT policy inserts the row successfully and then fails on the read-back, and the error PostgREST returns doesn’t distinguish the two.

// The insert succeeds even with zero SELECT policy on the table,
// because nothing asked for the row back.
const { error } = await supabase.from('orders').insert({ user_id: userId, total })

// Chaining .select() asks Postgres to read the new row and return it,
// which now needs a SELECT policy that matches that row.
const { data, error: selectError } = await supabase
  .from('orders')
  .insert({ user_id: userId, total })
  .select()

The workaround that still circulates for this is passing { returning: 'minimal' }. That option belonged to the v1 client; the current supabase-js, v2, already defaults to not returning the row, so there’s no option left to set, and neither the insert reference above nor the client’s published source lists returning as a valid parameter anymore. The fix is simpler than the outdated advice: don’t chain .select() unless you need the row back, or, if you do need it, add the SELECT policy the read-back requires.

Why you shouldn’t just disable RLS in Supabase to fix this

Three fixes make this error disappear without making the app safe, and I’ve seen the first two land in real audits, not hypotheticals.

One food-delivery app I audited had a notifications table added late, after the rest of the schema was built, and its row-level security was never switched on at all. The rest of the app had its own problems, but this table was the quiet one: no error, no policy, just a public read of every customer’s home address. A trigger copied every order’s customer name, phone number, delivery address, GPS coordinates, and total into that table. Supabase exposes every public table to the holder of the app’s own public key, so the fix that never happened would have been one line: turn RLS on. Disabling RLS on a table, or never enabling it, is the same failure with an extra step.

Another app in the corpus shipped the policy that makes this error impossible to ever see: an INSERT policy on its questions table that reads with check (true). No 42501 can ever fire there, because the check says yes to every insert, logged in or not. using (true) does the same thing to reads: Postgres sees a policy sitting there, and the table behind it has no protection at all.

The third is a service_role key reaching client-side code, sometimes on purpose, as a shortcut around a policy nobody wanted to write correctly. That key was built to skip row-level security entirely, for server code you control. In a browser bundle it hands out the same bypass to anyone who opens dev tools, the exact failure mode behind the holes AI coding tools ship by default.

Looks fixed
Actually fixed
RLS is disabled, and the insert works again
RLS is on, with a with check clause tied to the caller
The policy reads with check (true)
The policy compares the row to auth.uid(), not to true
The client uses the service_role key to skip the error
The service_role key never leaves server code
Looks fixed
RLS is disabled, and the insert works again
The policy reads with check (true)
The client uses the service_role key to skip the error
Actually fixed
RLS is disabled, and the insert works again
RLS is on, with a with check clause tied to the caller
The policy reads with check (true)
The policy compares the row to auth.uid(), not to true
The client uses the service_role key to skip the error
The service_role key never leaves server code

Across the AxonBuild audit corpus, RLS gaps turned up in 9 of 21 third-party apps, and 7 of 21 let one logged-in customer read or write another customer’s data outright. None of those apps were missing row-level security as a concept. Most had it, aimed at the wrong role, checking the wrong column, or quietly turned off on the one table someone added in a hurry.

A policy that says with check (true) and a missing policy produce the same access. Only one of them looks like it was fixed.

Test that the policy actually protects you

A policy that compiles isn’t a policy that works. The only way to know is to make two requests, one as each role the policy names, and read the results rather than the syntax.

  1. 01 Create two real accounts in your app, not two rows in the same session
  2. 02 Using the anon key with no session attached, try the insert you just wrote a policy for. It should fail
  3. 03 Log in as account A, insert a row, and confirm it succeeds and the returned data belongs to account A
  4. 04 Log in as account B, and try to insert a row using account A’s id in the owner column. It should fail even though B is authenticated
  5. 05 If you added .select(), confirm account B still cannot read a row that belongs to account A

A static check can tell you a policy exists. Only a second account, actually logged in, tells you whether it does anything.

Common questions about the Supabase RLS error

Do I need a SELECT policy just to insert a row?

No, not for the insert itself. You need one only if your code asks Postgres to hand the new row back, which .select() does. An insert with no .select() chained needs nothing more than the INSERT policy.

What is error 42501?

Postgres’s standard insufficient-privilege code. Supabase’s row-level security raises it whenever a request fails every applicable policy, whether the cause is a missing policy, a role mismatch, a null identity, or a read-back with no matching SELECT rule.

Should I just disable RLS to make it stop?

Only for a table nobody outside your own server ever queries directly, and even then it’s worth asking why RLS was on in the first place. For anything reachable through Supabase’s public API, disabling RLS hands read and write access to anyone holding the app’s public key, the same key that ships in your browser bundle.

Why is auth.uid() null when I’m logged in?

Usually because the request that hit Postgres never carried your session: an expired token, a server-side call still running on the anonymous key, or a client that never attached the Authorization header. Confirm the session in your app layer before assuming the policy is wrong.

How do I fix this for Storage uploads?

Storage keeps its policies on a separate table, storage.objects, so an RLS policy on your app’s own tables does nothing for uploads. Add an INSERT policy scoped to the bucket, and to the caller’s own folder if uploads should be private per user:

create policy "Users can upload to their own folder"
on storage.objects
for insert
to authenticated
with check (
  bucket_id = 'avatars'
  and (storage.foldername(name))[1] = (select auth.jwt()->>'sub')
);

This error, correctly read, is Postgres refusing to guess who owns a row you didn’t tell it how to check. That’s a narrower problem than “row-level security is broken,” and the fix is almost always smaller than the workaround. If you want the six causes checked against your actual policies rather than against this list, a human-verified audit walks every trust boundary with two real accounts, the same method behind the data-loss patterns AI-built apps ship by default and what it takes for an app to be ready to launch.