Guides / Storage upload: new row violates row-level security policy
Supabase Storage "new row violates row-level security policy" on upload: what it means and how to fix it
Your supabase.storage.from('avatars').upload(...) call fails with a 403 and the message new row violates row-level security policy. Storage keeps a metadata row for every file in the storage.objects table, and Row Level Security policies on that table decide who may upload. With no policy, Storage allows no uploads at all. The fix is a policy that matches your bucket, your file paths and your signed-in users. Making the bucket public or using a secret key in the browser is not a fix.
The short answer
Supabase's docs say Storage "does not allow any uploads to buckets without RLS policies" and that you allow operations "by creating RLS policies on the storage.objects table." A plain upload needs an INSERT policy. An upload with upsert: true also needs SELECT and UPDATE policies. The usual causes:
- There's no INSERT policy on
storage.objectsthat covers this bucket and this role. - You set
upsert: truebut only wrote an INSERT policy. This is the case Supabase's own troubleshooting article describes: the INSERT is fine, but Storage can't read the row back. - The file path doesn't match the policy. The policy expects
<user id>/avatar.pngand your app uploadsavatar.pngorpublic/avatar.png. - The request isn't signed in. With no session, or an expired one,
auth.uid()is null and ato authenticatedpolicy doesn't apply. - The
bucket_idin the policy is wrong, so the policy covers a different bucket.
If you're seeing the same message on a normal table insert rather than a file upload, see "new row violates row-level security policy for table", which covers table policies.
What each upload method needs
The Supabase JavaScript reference lists the policies each Storage method needs on storage.objects. None of these need anything on storage.buckets:
upload()of a new file:insert.upload()withupsert: true:select,insertandupdate.update(), which always replaces the file at that path:updateandselect.createSignedUploadUrl():insert. Uploading withuploadToSignedUrl(): none.
Without upsert, uploading to a path that already exists returns a 400 Asset Already Exists error, not the RLS error. If you see the RLS error only when a user replaces a file, look at your SELECT and UPDATE policies first.
How to debug it
1. List the policies on storage.objects
Run this in the SQL Editor:
select policyname, cmd, roles from pg_policies where schemaname = 'storage' and tablename = 'objects' order by policyname;
You need an INSERT row whose roles include authenticated (or public, which means every role) if your users are signed in. If you upload with upsert: true, you also need SELECT and UPDATE rows. Open each policy in the Dashboard to read its full expression, and check that bucket_id matches your bucket's ID exactly.
2. Compare the policy with the path you upload to
Per-user folder policies, like the examples in Supabase's docs, use storage.foldername(name), which returns the folders a file sits in. For public/subfolder/avatar.png it returns ['public', 'subfolder'], so (storage.foldername(name))[1] is 'public'. If the policy compares that first folder with the user's ID, the path has to start with the user's ID. A file uploaded straight to the bucket root, like avatar.png, has no first folder to match. Log the exact path your code passes to upload() and check it against the policy.
3. Check who the request runs as
auth.uid() returns the ID of the user making the request, and null when there's no access token or the session has expired. A request made with only the publishable (anon) key and no user session runs as anon, so a to authenticated policy never applies to it. Make sure the upload happens after sign-in completes, from a client that holds the user's session.
Server code is a common surprise here. Supabase says a secret key bypasses RLS "only when the request carries no user access token. If the request carries one, it runs under the RLS policies of that signed-in user, even when the client library was initialized with a secret key." So a server route that forwards the user's token still needs the right policies.
4. Check the upsert option
Search your code for upsert: true and for an x-upsert header. Supabase's troubleshooting article says that with upsert: true, Storage runs the upload as INSERT ... ON CONFLICT followed by RETURNING *. If no SELECT policy covers the new object, the row can't be returned and the whole upload fails with a 403, even when your INSERT policy is correct.
How to fix it
Let signed-in users upload to their own folder
This follows Supabase's own example of uploads to a folder named with the user's ID: each user writes only under their own folder.
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.uid()::text) );
The folder name is text and auth.uid() is a UUID, hence the ::text. Supabase's Access Control example uses (select auth.jwt() ->> 'sub') instead, which reads the same user ID from the token as text. Then upload to a path that starts with the user's ID:
const { data: { user } } = await supabase.auth.getUser()
if (!user) throw new Error('Not signed in')
const { data, error } = await supabase.storage
.from('avatars')
.upload(`${user.id}/avatar.png`, file)
if (error) console.error(error)
If you overwrite files, add SELECT and UPDATE
First ask whether you need upsert at all. Supabase suggests leaving it at the default false if you don't overwrite files, and notes that uploading to a new path avoids stale copies on its CDN. If you do overwrite, add policies with the same scope as your INSERT policy:
create policy "Users can read their own files" on storage.objects for select to authenticated using ( bucket_id = 'avatars' and (storage.foldername(name))[1] = (select auth.uid()::text) ); create policy "Users can replace their own files" on storage.objects for update to authenticated using ( bucket_id = 'avatars' and (storage.foldername(name))[1] = (select auth.uid()::text) ) with check ( bucket_id = 'avatars' and (storage.foldername(name))[1] = (select auth.uid()::text) );
On an UPDATE policy, using checks the existing row and with check checks the row after the change, so a user can't move a file into someone else's folder.
Uploads that start on your server
If your server decides who may upload, it can create a signed upload URL with createSignedUploadUrl() and hand it to the browser, which uploads with uploadToSignedUrl(). Supabase says signed upload URLs work "without further authentication" and are valid for 2 hours. The secret key stays on the server.
Don't "fix" it by opening everything up. Making the bucket public doesn't help: public buckets skip access control for reading files, but Supabase says access control "is still enforced for other types of operations including uploading." An INSERT policy of with check (true) with no to clause and no bucket_id check runs for signed-out visitors too, so anyone can upload files to any bucket in your project. And don't put the service_role key or a secret key in your frontend to get past the error. Service keys "entirely bypass RLS policies," and Supabase says never to use a secret key in the browser. See Supabase Storage bucket set to public? and service_role key exposed in client code.
Confirm it worked
Sign in as a test user and upload to <their id>/test.png. It should succeed. Upload to another user's ID as the folder and it should fail. Sign out and try again: that should fail too. If you use upsert: true, upload the same path twice and the second upload should replace the first.
free check
Did a secret key end up in your frontend?
If you pasted a service_role or secret key into client code while chasing this error, LockedVibe's free Leak Checker can look at your live site for exposed keys, the way any visitor's browser would. It can't see your Storage policies.
Sources
Every Supabase and Postgres detail above was checked against these official pages. Last checked Oct 2026. If behavior changed, the docs below are the source of truth.
- Supabase: Storage error 403 "new row violates row-level security policy" on upload (upsert, RETURNING *, SELECT policy)
- Supabase: Storage Access Control (no uploads without policies, INSERT for upload, SELECT and UPDATE for upsert, policy examples, service key)
- Supabase: Storage Helper Functions (storage.foldername)
- Supabase: Storage Ownership (owner_id and auth.uid()::text)
- Supabase JavaScript reference (RLS permissions for upload, update, createSignedUploadUrl, uploadToSignedUrl)
- Supabase JS: storage upload
- Supabase JS: createSignedUploadUrl
- Supabase: Standard Uploads (400 Asset Already Exists, upsert, CDN)
- Supabase: Storage Buckets (public buckets still enforce upload access control)
- Supabase: Row Level Security (auth.uid() null, roles, UPDATE using and with check, secret keys)
- Supabase: API keys (publishable key runs as anon, secret key bypasses RLS)
- Supabase: Tables and data (listing policies with pg_policies)
- Supabase: The Storage Schema (storage.objects metadata, bucket_id)
- PostgreSQL: CREATE POLICY (roles default to PUBLIC, all roles)