Home

Fix this error · Supabase and Firebase

Fix: new row violates row-level security policy for table

Fix Supabase RLS error 42501: insert and update policies with auth.uid(), why a blocked select returns [] with no error, and the secret key server-only.

Postgres refused a write because no row-level security (RLS) policy allows it for the role making the request. In Supabase this usually means the table has RLS on but no insert policy for authenticated, or the policy's with check does not match the row you sent, often because user_id is missing or the user is not signed in. Add a policy that compares (select auth.uid()) to the owner column, and make sure the request carries the user's session.

FactDetail
Errornew row violates row-level security policy for table "<table>"
Postgres code42501 (PostgREST answers 403 when signed in, 401 when not)
Where you see itThe error object returned by supabase-js, the network tab, Supabase API logs
Usual causeNo insert (or update) policy for the role, or with check fails for the row sent
Usual fixAdd a policy using (select auth.uid()) = user_id and send user_id that matches the signed-in user
BugTape catches itThe failed request, automatically, when it follows a click within 8 seconds. Silent empty reads only with a rule you add

What the error means

With RLS enabled, Postgres checks every row against the table's policies. For reads and deletes, the using expression filters rows. For inserts and updates, the with check expression is checked against the new row. When the check is false or null, Postgres aborts the whole command with new row violates row-level security policy for table "<table>" and SQLSTATE 42501.

Supabase sends browser requests through PostgREST using either the anon role (no session) or the authenticated role (a signed-in user). The publishable (anon) key gets no data at all once RLS is on, until you write policies. supabase-js does not throw: it returns { data: null, error }, and error.code is '42501'.

Reads behave differently. A select that RLS blocks does not fail. It returns the rows the policy allows, which may be none, so you get [] and error: null. Updates and deletes on rows you cannot see also match zero rows without an error.

Common causes

  1. No policy for the operation. RLS is on and there is a select policy, but no insert or update policy. Default deny applies.
  2. The user is not signed in when the request runs. auth.uid() returns null, so auth.uid() = user_id is null and the check fails. This happens when the insert runs before the session loads, or on the server with a client that has no cookies.
  3. The row does not carry the owner. The insert omits user_id, or sends a different id, so with check ((select auth.uid()) = user_id) is false.
  4. .insert().select() without a select policy. Returning the inserted row requires a select policy that the new row passes. Postgres raises the error instead of hiding the row.
  5. An upsert that hits an existing row. An upsert that turns into an update needs an update policy as well as insert.
  6. The policy targets the wrong role. It is written to anon and the user is signed in, or to authenticated and the request has no session.
  7. A server client with the secret key also carries a user session. The secret key bypasses RLS only when the request has no user access token. A client that has the user's session runs under that user's policies.

A missing grant also raises 42501, but its message is permission denied for table <table>. That is a grants problem, which Postgres checks before any policy.

How to fix it

Add insert, select and update policies that use auth.uid()

Supabase recommends wrapping auth.uid() in a select so Postgres evaluates it once per statement instead of once per row.

alter table public.todos enable row level security;

create policy "Users can read their own todos"
on public.todos for select
to authenticated
using ( (select auth.uid()) = user_id );

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

create policy "Users can update their own todos"
on public.todos for update
to authenticated
using ( (select auth.uid()) = user_id )
with check ( (select auth.uid()) = user_id );

Fill user_id from the database

Let Postgres set the owner so the client cannot send the wrong value or forget it.

alter table public.todos
  alter column user_id set default auth.uid();

const { data, error } = await supabase
  .from('todos')
  .insert({ title: 'Ship it' }) // user_id comes from the default
  .select()
  .single();

if (error) console.error(error.code, error.message); // '42501' when a policy blocks it

The .select() here needs the select policy above. Without it the insert itself is rolled back with the same error.

Make sure there is a session before you write

Check the user first. On the server in Next.js, use a client from @supabase/ssr so the request carries the user's cookies. A plain createClient on the server has no session and runs as anon.

const { data: claims } = await supabase.auth.getClaims();
if (!claims) throw new Error('Sign in required');

await supabase.from('todos').insert({ title: 'Ship it' });

Use the secret key only on the server

The secret key (and the legacy service_role key) uses a Postgres role with bypassrls, so policies do not apply to it. That makes it right for trusted jobs and webhooks, and wrong for any code that ships to the browser. Never use it to make an RLS error go away in client code. Fix the policy instead. Keep the secret-key client separate from any client that holds a user session, because a request with a user token runs under that user's policies.

// server-only file, e.g. a cron job or webhook handler
import { createClient } from '@supabase/supabase-js';

const admin = createClient(process.env.SUPABASE_URL!, process.env.SUPABASE_SECRET_KEY!);

Find which policy fails

Run the statement as the user in the SQL editor to see the policy result without the app in the way.

begin;
set local role authenticated;
set local request.jwt.claim.sub = '<user-uuid>';
insert into public.todos (title, user_id) values ('test', '<user-uuid>');
rollback;

How to catch it in production with BugTape

supabase-js returns the error instead of throwing it, so no JavaScript error reaches the window. What BugTape can see is the HTTP request. A blocked write comes back from /rest/v1/<table> as 401 or 403. The browser SDK reports a failed fetch or XHR automatically when it follows a click within 8 seconds, so a Save button that hits this error becomes an issue with the request URL, the status, the click and the steps before it. supabase-js calls the global fetch at request time, so the SDK sees these requests whether the client was created before or after init(). A write that is not tied to a click, such as one on page load or on a timer, is not reported automatically.

To put the Postgres message in the issue, rethrow it through the browser's reportError(), which fires the same window error event the SDK listens for:

const { error } = await supabase.from('todos').insert({ title });
if (error) {
  reportError(new Error(`${error.code} ${error.message}`));
}

A select blocked by RLS returns [] with status 200, so nothing fails. BugTape cannot tell an empty list from a correct one unless you tell it which responses must not be empty. autoReport.emptyExpectRules takes rules of the form { match, expect, minItems?, methods? }, where match is a substring or regular expression for the path and expect is 'nonempty_array', 'nonempty_json' or 'nonempty_text'. Like failed requests, an empty response is reported only when it follows a click within 8 seconds. Only add a rule for a list that should never be empty for a signed-in user.

import { init } from 'https://app.bugtape.ai/bugtape.mjs';

init({
  apiKey: 'YOUR_PROJECT_KEY',
  release: 'YOUR_COMMIT_SHA',
  autoReport: {
    emptyExpectRules: [{ match: '/rest/v1/projects', expect: 'nonempty_array' }],
  },
});

For reads on page load, check the result in code and call reportError(new Error('projects returned no rows for a signed-in user')) where an empty result is a bug.

Repeats on the same page group into one issue with a count. Set release so that when you mark the issue fixed, a report from a newer release reopens it as a regression. Your coding agent can read the issue over MCP (npx -y bugtape connect claude, or cursor or codex) and propose the policy change. You review the pull request.

FAQ

Why does my select return an empty array with no error?

RLS filters rows for select. A row the policy does not allow is left out, and Postgres does not raise an error. If every row is filtered, you get []. Check that a select policy exists for the role and that the request is signed in, because auth.uid() is null for anon requests.

Should I disable RLS or use the service role key to fix it?

No. With RLS off, anyone with your publishable key can read and change the table through the API. The secret key bypasses RLS, so it must stay on the server. Write the policy the operation needs instead.

Why do I get the error on insert when I already have a select policy?

Each operation needs its own policy. An insert needs an insert policy with a with check that the new row passes. If you also call .select() after the insert, the new row must pass the select policy too.

Why does the same insert work in the SQL editor but fail from the app?

The SQL editor runs as postgres by default, which has bypassrls on Supabase. The app runs as anon or authenticated. Test with set local role authenticated and the user's claims, as shown above, to see what the app sees.

Sources

All guides: Fix an error.

Start free. Free plan · No credit card · Set up in minutes. Or try the live demo first.