BusiRocket

Article

Supabase "new row violates row-level security policy": six causes and the safe fix

Published on

You click save, or upload a photo, or finish signing up, and the app shows nothing. In the browser console or the network tab there is a 403 with this:

new row violates row-level security policy for table "profiles"

with code 42501. The table name changes (profiles, orders, products, objects for Storage uploads), but the cause is usually one of six.

The error means Row Level Security is on for that table and no policy allowed this particular write for this particular user. That is RLS doing its job. The fix is to make the right policy match, not to make every policy match.

The wrong fix, first

If you ask Lovable or Bolt to fix this error, the quickest change that makes it disappear is one of these:

alter table orders disable row level security;
-- or
create policy "Allow all" on orders for all using (true) with check (true);

Both make the error go away. Both also let any visitor with your public anon key, which ships in every browser that loads your app, read and change every row in the table. The app keeps working and you get no warning; the leak only shows up when someone goes looking. If your project has a policy with using (true) on a table that holds user data, treat that as a bug even if nothing is failing. The security checklist for vibe-coded apps explains how to test for it.

Now the real causes, roughly from most to least common in AI-generated apps.

1. The insert does not set user_id

The typical policy says a user may insert a row if the row belongs to them:

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

If the frontend inserts { total: 40 } without user_id, the column is null, auth.uid() = null is not true, and the insert is rejected. Fix it in the database rather than trusting every insert in the frontend to remember:

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

Now the column fills itself from the session and the policy passes.

2. There is no logged-in session on that request

auth.uid() reads the user from the JWT sent with the request. If there is no JWT, the request runs as the anon role and auth.uid() is null, so every "own rows" policy fails. Ways this happens:

  • The insert runs before the session has loaded, for example in a component that fires on mount while getSession() is still resolving.
  • The policy says to authenticated but the feature is meant to work for visitors (a contact form, a waitlist). Then you need an explicit, narrow insert policy for anon, not the user policy.
  • The write happens in an edge function that creates its own Supabase client with the anon key and does not forward the caller's Authorization header.

To check which role a request used, open the network tab, find the failing request and look at its Authorization header. Decode the token (jwt.io works, but do not paste production tokens into random sites; any local decoder is fine) and read the role claim: anon or authenticated.

3. Profiles inserted from the browser right after signUp

This is why "for table profiles" is the most common version of the error. The app calls supabase.auth.signUp(), then immediately inserts a row into profiles. With email confirmation turned on, signUp returns no session until the user clicks the link, so the profile insert runs as anon and fails. In preview it often worked because confirmation was off.

The robust pattern, and the one Supabase documents, is to create the profile in the database with a trigger on auth.users, so it never depends on the browser:

create function public.handle_new_user()
returns trigger
language plpgsql
security definer set search_path = ''
as $$
begin
  insert into public.profiles (id, full_name)
  values (new.id, new.raw_user_meta_data ->> 'full_name');
  return new;
end;
$$;

create trigger on_auth_user_created
  after insert on auth.users
  for each row execute procedure public.handle_new_user();

Pass extra fields through options.data in signUp and read them from raw_user_meta_data as above. Then delete the client-side insert. If the trigger itself fails, signup fails with a "Database error saving new user" message, so test it once with a real signup.

4. The insert asks for the row back, and SELECT is not allowed

With supabase-js, .insert(...).select() inserts the row and then returns it. Returning the row needs a SELECT policy as well as the INSERT policy. If you only wrote the insert policy, the write is refused with the same "new row violates" message, which sends people looking at the wrong policy.

Either add a select policy for the rows the user owns, or drop .select() when you do not need the row back.

5. Upsert needs three policies, not one

.upsert() is an insert that turns into an update when the row exists. It needs INSERT, UPDATE and SELECT policies that all match. AI-generated code uses upsert freely for "save settings" style features, and the policies usually only cover insert. The error appears the second time the user saves, which makes it look random.

6. Storage uploads: the policy is on storage.objects

For uploads, the table in the message is objects, in the storage schema. Marking a bucket as public only makes its files readable by URL. It does not allow anyone to upload. Uploads need an INSERT policy on storage.objects, and overwriting an existing file (upsert: true in the upload options) also needs SELECT and UPDATE.

The usual pattern stores each user's files in a folder named after their ID:

create policy "Users upload to own folder"
on storage.objects for insert to authenticated
with check (
  bucket_id = 'avatars'
  and (storage.foldername(name))[1] = (select auth.uid())::text
);

For that policy to pass, the upload path must start with the user's ID, for example USER_ID/avatar.png. An upload to avatar.png at the root of the bucket fails, and so does a path that uses the profile ID when that differs from the auth user ID.

How to find which one you have

Work from the failing request, not from the policies. In the network tab you can see the table, the method (POST for insert, PATCH for update, POST with a Prefer: resolution=merge-duplicates header for upsert), whether the query string asks for the row back (select= in the URL), the body (is user_id there?) and the token's role. That narrows it to one of the six sections above in a minute or two.

Then reproduce it in the Supabase SQL editor as that user, not as the postgres superuser that bypasses RLS:

begin;
set local role authenticated;
set local request.jwt.claims = '{"sub": "PUT-THE-USER-UUID-HERE", "role": "authenticated"}';
insert into orders (total) values (40);
rollback;

If that fails, the policy or the defaults are wrong. If it succeeds, the problem is the request the app sends.

Server code that must write for any user

Some writes legitimately have no logged-in user: a Stripe webhook updating a subscription, a cron job, an admin action. Those run on the server with the service_role key, which bypasses RLS. That key belongs in edge function or server secrets only. If it appears in frontend code, or in any variable prefixed VITE_ or NEXT_PUBLIC_, rotate it today; anyone who loaded your app has it. Payment handlers are where we see this most, which is one of the causes covered in Lovable Stripe payments not working in production.

When the policies are past patching

One RLS error is a policy to correct. An app where every new feature trips another one, and where the AI has answered each with a broader policy, usually needs the policies rewritten table by table and tested with two accounts. That is a core part of our vibe code rescue audit: 450 EUR, 48 working hours, a written list of which tables are exposed and which policies are wrong, each fix priced, and the fee deducted if you continue into the rescue (1,500-6,000 EUR, fixed scope).

If the error only shows up on your live domain and never in preview, check the other differences between the two environments in why a Lovable app works in preview but not in production; a missing session after a broken login redirect is a frequent root cause, covered in Supabase auth redirects to localhost.

Next step

Does this problem sound like yours?

Tell us about it and we will say how we would approach it, in what order and at what cost.