File upload fails with "new row violates row-level security policy". Why?

Checked on

Supabase Storage keeps every file as a row in the storage.objects table, and that table has Row Level Security on. With no policy, nobody can upload. Add an INSERT policy on storage.objects for your bucket, add a matching SELECT policy so the upload can return its metadata, and add UPDATE too if you use upsert: true. Don't "fix" it by putting the service_role or secret key in the browser.

Symptoms

  • Uploading an avatar, image or document fails with HTTP 403 and new row violates row-level security policy.
  • The upload works in the dashboard but not from your app.
  • First uploads work, but replacing a file (upsert: true) fails.
  • Deleting a file does nothing, with no error, or fails.

Why it happens

From Supabase's docs: "By default Storage does not allow any uploads to buckets without RLS policies. You selectively allow certain operations by creating RLS policies on the storage.objects table" (Supabase: Storage access control).

The causes, in the order we'd check them:

  1. No INSERT policy for the bucket, or one that doesn't match the path you upload to.
  2. No matching SELECT policy. Supabase's troubleshooting article explains that the Storage API runs an INSERT ... RETURNING *. If no SELECT policy covers the new object, "the database is unable to return the row metadata" and the whole upload fails, even if your INSERT policy is correct (Supabase troubleshooting: Storage 403 on upload).
  3. upsert: true without SELECT and UPDATE. "To allow overwriting files using the upsert functionality you will need to additionally grant SELECT and UPDATE permissions" (Storage access control).
  4. The path doesn't match the policy. A policy that only allows <user id>/... rejects an upload to avatars/photo.png.
  5. The user isn't signed in. A policy to authenticated won't match a request made with only the public key and no user session.

Step-by-step fix

Run these in the Supabase SQL Editor. Replace avatars with your bucket id.

1. Allow signed-in users to upload into their own folder

This follows Supabase's documented example (Storage access control):

create policy "Users 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')
);

storage.foldername(name) returns the folders in the file path (Supabase: Storage helper functions), so [1] is the first folder.

2. Let them read what they uploaded

create policy "Users read their own files"
on storage.objects for select
to authenticated
using (
  bucket_id = 'avatars'
  and (select auth.jwt() ->> 'sub') = owner_id
);

The owner_id comparison is Supabase's documented example. Make sure it covers the object being created, which it will for the uploading user.

3. If you use upsert: true, allow updates too

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

For deletes you need a DELETE policy as well. A Supabase engineer notes in supabase/storage#1241 that delete also needs SELECT.

4. Upload to the path the policy expects

const { data: { user } } = await supabase.auth.getUser()
const { error } = await supabase.storage
  .from('avatars')
  .upload(`${user!.id}/avatar.png`, file, { upsert: true })

5. Don't use the secret key in the browser

Service keys "entirely bypass RLS policies" (Storage access control). That's why AI tools sometimes suggest them as a quick fix. In browser code, that hands your whole storage and database to every visitor. If it's already happened, see Supabase service_role key exposed?

Fix prompt for your AI builder

Paste this into Lovable, Bolt, Cursor, Replit, v0 or whichever tool you build with:

Fix prompt
File uploads to Supabase Storage fail with 403 "new row violates row-level security policy". Fix only the storage policies and the upload path:
1. Write a SQL migration with policies on storage.objects for bucket '<BUCKET>': INSERT for authenticated users where (storage.foldername(name))[1] = (select auth.jwt() ->> 'sub'); SELECT where (select auth.jwt() ->> 'sub') = owner_id; UPDATE with the same conditions (we use upsert); DELETE only if the app deletes files.
2. Change the upload code to upload to `${user.id}/<filename>` and make sure the user is signed in before uploading.
3. Do NOT use the service_role or secret key in frontend code, and do not disable RLS.
Show me the SQL before running it, and list the files you changed.

How to confirm it's fixed

  1. Sign in as a test user on the live site and upload a file. It should succeed and appear under <user id>/ in the bucket.
  2. Upload again with the same name (if you use upsert). It should replace the file.
  3. Sign in as a second test user and confirm they can't read or overwrite the first user's file.

How to find out sooner next time

Heedza doesn't upload files or sign in, so it can't test this flow. It checks that your landing, signup, login and checkout pages load with their forms, and sends a screenshot and fix prompt when one doesn't. One check is relevant here, though: if a "quick fix" puts a Supabase service_role key into your frontend, the free scan flags it as an exposed key.

Let Heedza check this for you. Heedza’s security checks looks for secret keys left in your page code and private files anyone can open. It checks again every time it checks your pages. It never logs in or fills in forms, so it can’t see errors that only show after that.

Made with Lovable, Bolt, Replit, Cursor or v0? Free for 1 site: start free or see prices.

More questions

Does a public bucket need these policies?

Reading files from a public bucket doesn't, because public buckets are already publicly readable (Storage access control). Uploading still needs an INSERT policy.

Can I just use `with check (true)`?

For uploads only, it works, but then any signed-in user (or anyone, if you drop to authenticated) can write anywhere in the bucket. Scope it to the bucket and the user's folder.

I added every policy and it still fails.

Check the exact path you upload to against the policy, and that the request carries a signed-in session. Supabase's troubleshooting article also suggests checking that your SELECT policy mirrors the INSERT conditions.

Where this comes from