HomeGuidesSupabase Storage upload new row violates RLS policy fix

Guide

Supabase Storage upload new row violates RLS policy fix

Supabase Storage upload or upsert fails with new row violates row-level security policy? The storage.objects policies each case needs, tested.

14 min read

Independent notes from VibeLocked. Not a pentest, and not affiliated with the tools named below.

You call upload() on a Supabase Storage bucket and get new row violates row-level security policy. Or the first upload works and the second one, with upsert: true, fails with the same message. Storage keeps one row per file in the storage.objects table, RLS is on for that table, and each upload mode needs its own set of policies.

This guide shows which policies a plain upload, an upsert and update() need, how to read the error, and the policy set that lets users replace their own files without replacing anyone else's. For upserts on your own tables, see the table upsert guide. For bucket privacy and signed URL basics, see Supabase Storage bucket security.

Tested on October 26, 2026. We ran the open source Supabase Storage server v1.84.25 (released the same day) from source, with its own 75 migrations, on PostgreSQL 17.11, with @supabase/supabase-js 2.118.0 and two signed in test users. The server used its local file backend instead of S3. That came to 63 checks, all passing. Hosted Supabase projects may run a different Storage version. Every claim below is either something we reproduced or a quote from the docs linked at the end. VibeLocked is independent and not affiliated with Supabase.

The short version

  1. Plain upload of a new file needs an INSERT policy on storage.objects.
  2. upsert: true on a new path needs INSERT and SELECT.
  3. Overwriting a file with upsert: true (the x-upsert: true header) or update() needs INSERT, SELECT and UPDATE.
  4. Listing and signed URLs need SELECT. Without it, list() returns an empty array and createSignedUrl says Object not found.
  5. Every policy checks bucket_id and the first folder against the user's id. Bucket only policies let one user overwrite another user's file.
  6. The error text does not tell you which policy failed. Storage returns the same message for all of them.

Supabase's access control docs put the rule this way: "the only RLS policy required for uploading objects is to grant the INSERT permission to the storage.objects table." And: "To allow overwriting files using the upsert functionality you will need to additionally grant SELECT and UPDATE permissions."

Why uploads hit RLS at all

The same docs say: "By default Storage does not allow any uploads to buckets without RLS policies." Before Storage writes your file, it runs the database insert for the file's row inside a transaction and rolls it back, as a permission test. If Postgres rejects the row, the upload stops there and no file is stored. In the server source this is testPermission in the uploader.

A plain upload runs an INSERT INTO storage.objects. An upsert runs this, which we copied from our Postgres statement log:

INSERT INTO storage.objects ("name", "bucket_id", "owner", "owner_id", "metadata", "version", ...)
VALUES (...)
ON CONFLICT (bucket_id, name COLLATE "C") WHERE archived_at IS NULL
DO UPDATE SET "owner" = EXCLUDED."owner", "owner_id" = EXCLUDED."owner_id", "metadata" = EXCLUDED."metadata", ...
RETURNING *, ...

That ON CONFLICT DO UPDATE is why upserts need more policies. The PostgreSQL CREATE POLICY docs explain it:

  • "Note that an INSERT with an ON CONFLICT DO NOTHING/UPDATE clause will check the INSERT policies' WITH CHECK expressions for all rows proposed for insertion, regardless of whether or not they end up being inserted."
  • "If an INSERT has an ON CONFLICT DO UPDATE clause, or an ON CONFLICT DO NOTHING clause with an arbiter index or constraint specification, then SELECT permissions are required on the relation, and the rows proposed for insertion are checked using the relation's SELECT policies."
  • On the update path, "unlike a standalone UPDATE command, if the existing row does not pass the USING expressions, an error will be thrown (the UPDATE path will never be silently avoided)."

Which policies each operation needed

Each column is a separate test run with only the listed policies in place. Every policy had the same owner folder check shown in the next section.

Operation INSERT only INSERT + SELECT INSERT + UPDATE, no SELECT INSERT + SELECT + UPDATE
upload() new path works works works works
upload() existing path, no upsert The resource already exists not tested not tested The resource already exists
upload(..., { upsert: true }) new path fails works fails works
upload(..., { upsert: true }) existing file fails fails fails works
update() existing file not tested fails not tested works
list() own folder empty array, no error works not tested not tested
createSignedUrl() Object not found works not tested not tested

Every "fails" above returned new row violates row-level security policy. update() takes the same path as an upsert in the server code (the PUT route sets isUpsert: true), and it behaved like one.

How to read the error

In supabase-js 2.118.0 the error object looked like this:

{ name: 'StorageApiError', status: 400, statusCode: '403', code: 'AccessDenied',
  message: 'new row violates row-level security policy' }

The raw HTTP response was status 400 with this body:

{"statusCode":"403","error":"Unauthorized",
 "message":"new row violates row-level security policy","code":"AccessDenied"}

So error.status is 400 even for an RLS denial. Check error.statusCode and error.code instead.

Postgres itself raised two different messages. A failed INSERT or SELECT check gave new row violates row-level security policy for table "objects". Overwriting a file with no matching UPDATE policy gave new row violates row-level security policy (USING expression) for table "objects". Storage turns both into the same short message: its database error mapping replaces any 42501 message that mentions row-level security. If you can read your Postgres logs, the (USING expression) version points at the UPDATE policy. Otherwise use the table above and remove policies one at a time.

Decision table: error to fix

What you see What caused it in our test Fix
new row violates row-level security policy on a plain upload() No INSERT policy, or the path failed its with check: a different bucket, a first folder that was not the user's id, a file at the bucket root, or a request with only the anon key while the policy is to authenticated Add the owner folder INSERT policy below. Build the path as ${user.id}/file from the session
Same message only with upsert: true or update() Missing SELECT policy (even for a new path), missing UPDATE policy (for an existing file), or the file is in another user's folder Add owner folder SELECT and UPDATE policies. If the file belongs to another user, RLS is working: fix the path
The resource already exists (statusCode 409, code KeyAlreadyExists) The path already had a file and the upload did not set upsert: true Set upsert: true and add the SELECT and UPDATE policies, or upload to a new unique path
permission denied for table objects We revoked the table grant. Storage passes this one through unchanged Restore the grant. Storage's migrations grant these tables to anon and authenticated, so check for a migration that revoked them
operator does not exist: text = uuid when creating the policy (storage.foldername(name))[1] = auth.uid() compares text to uuid Use (select auth.uid())::text or (select auth.jwt()->>'sub')
createSignedUrl returns Object not found for a file you just uploaded No SELECT policy matched the file Add the owner folder SELECT policy
list() returns [] with no error Same: no SELECT policy matched Same
Works with the service role key, fails with the user's session The service role bypasses RLS, so it hides missing policies Fix the policies. Never ship the service role key to the browser

The policy set that works

Store each user's files under a folder named with their user id, such as avatars/<user_id>/photo.png. For a file at <user_id>/photos/x.png, storage.foldername(name) returned {<user_id>,photos} in our test, so [1] is the user id folder.

create policy "Users read 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 upload into own folder"
on storage.objects for insert
to authenticated
with check (
  bucket_id = 'avatars'
  and (storage.foldername(name))[1] = (select auth.uid())::text
);

-- only if your app overwrites files (upsert: true or update())
create policy "Users replace 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
);

With these three, user A could upload, upsert, update(), list and sign URLs for files in their own folder. User B got new row violates row-level security policy when trying to upsert or update() a file in A's folder, and A got the same error when trying to move a file into B's folder. A's file was unchanged. Add a DELETE policy with the same using if users delete files, as in the bucket security guide.

On the UPDATE policy, keep the same check in with check. The PostgreSQL docs say that "if no WITH CHECK expression is defined, then the USING expression will be used both to determine which rows are visible (normal USING case) and which new rows will be allowed to be added ( WITH CHECK case)." An UPDATE policy with only using worked the same in our test, but writing both makes the intent clear when someone edits it later.

Build the path from the session, never from a form field:

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

The overwrite risk: what a broad policy allows

The common fix for this error, from AI tools and forum answers, is to drop the folder check and keep only bucket_id = 'avatars'. Supabase's own docs show that form as a starting point to restrict uploads to one bucket. For an app where users replace files, it is not enough. With bucket only INSERT, SELECT and UPDATE policies in our test:

  • User B upserted onto user A's path <A's id>/a.txt and got a success response.
  • The stored file content became B's.
  • owner_id on the row switched from A's id to B's id.
  • B could list everything in A's folder.

So bucket only policies let any signed in user replace any avatar, receipt or document in that bucket. The path, and so the file URL, stays the same, but the file behind it is now B's.

owner_id and folder checks

Supabase's ownership docs say the owner "is the user who created the resource and the value is derived from the sub claim in the JWT. We store the owner in the owner_id column." They add: "When using the service_key to create a resource, the owner will not be set and the resource will be owned by anyone." And owner "is deprecated and will be removed. Use owner_id instead."

What we saw:

  • A user's upload set owner_id to their id. A service role upload left it NULL.
  • An upsert overwrite rewrote owner_id to whoever did the overwrite.
  • An UPDATE policy of owner_id = (select auth.uid())::text blocked user B from overwriting user A's file, even with bucket wide INSERT and SELECT.
  • The same owner_id policy also blocked a user from overwriting a file the server had created for them with the service role, because that row's owner_id was NULL. A folder check allowed it.

Use the folder check as the main rule. It is tied to the path, which is what the file URL uses, and it does not depend on who wrote the row first. Add an owner_id check too if files in one folder can come from more than one user.

Public buckets and signed URLs, briefly

The bucket docs say a public bucket "effectively bypasses access controls for both retrieving and serving files within the bucket." They add: "Access control is still enforced for other types of operations including uploading, deleting, moving, and copying." We saw exactly that. With only an INSERT policy, the public URL served the file with no auth and no SELECT policy, the anon key still could not upload, and upsert: true still needed SELECT and UPDATE. When we made the bucket private again, the same public URL stopped working.

For private buckets, createSignedUrl needs a SELECT policy that matches the file, and the signed URL then downloaded the file with no auth header. Keep expiry short. The bucket security guide covers why.

If you upload with createSignedUploadUrl instead, the INSERT policy (plus SELECT and UPDATE for upsert: true) is checked when the token is made, not when the file arrives. Our signed upload URL guide tests token reuse, expiry and how to block listing while still allowing downloads.

Supabase's standard uploads docs also "advise against overwriting files when possible, as our Content Delivery Network will take sometime to propagate the changes to all the edge nodes leading to stale content." Uploading to a new path, such as one with a timestamp, avoids that and means you may not need an UPDATE policy at all.

Checklist

  • RLS policies exist on storage.objects for every operation your app uses: INSERT for uploads, SELECT for upsert, list and signed URLs, UPDATE only if files are overwritten
  • Every policy checks bucket_id and (storage.foldername(name))[1] = (select auth.uid())::text
  • No storage policy uses true, or only bucket_id, for INSERT or UPDATE on a bucket where users have their own files
  • UPDATE policies have the owner folder check in using and with check
  • The client builds paths from auth.getUser(), not from form input
  • Private files live in a private bucket and are shared with short lived signed URLs
  • No service role or secret key in frontend code
  • A test signs in as one user and tries to upsert, update() and move another user's file, expecting an error

The RLS testing guide shows how to run that last check in CI, and the Supabase RLS checklist covers your tables.

Common AI generated mistakes

  • with check (true) or a bucket only check to make the error go away. That is the overwrite risk above.
  • Adding only an INSERT policy, so first uploads work and every replace fails.
  • An UPDATE policy without a SELECT policy, so upserts still fail, even on new paths.
  • Comparing storage.foldername(name) to auth.uid() with no cast, which fails with text = uuid.
  • Uploading to the bucket root, such as avatar.png, when the policy expects a user id folder.
  • Switching to the service role key in the browser so uploads work. It bypasses every policy.
  • Reading error.status, which was 400, and concluding it is a bad request instead of an RLS denial.

Copy-paste prompt for your AI coding tool

Paste this into Cursor, Claude Code, Codex, Lovable, Bolt, or Replit inside your own project. It asks for SQL and file names only. Do not paste database passwords, connection strings, or keys into the chat.

This project uses Supabase Storage. Do not print, log, or ask me for any secret values. 1) Find every storage call (upload, update, upsert option, move, copy, remove, list, createSignedUrl, createSignedUploadUrl) and list the bucket, how the path is built, and whether upsert is true. Flag any path that does not start with the signed in user's id from auth.getUser(), and any path built from form input. 2) Write a read only SQL query that lists every policy on storage.objects from pg_policies with its command, roles, using and with check. 3) Flag any INSERT, UPDATE or ALL policy that uses true, checks only bucket_id, applies to anon or public without a reason, or does not compare (storage.foldername(name))[1] to (select auth.uid())::text. 4) For each bucket, say which policies the code needs: INSERT for uploads, SELECT for upsert, list and signed URLs, UPDATE for upsert overwrites and update(). 5) Write one migration with owner folder SELECT, INSERT and UPDATE policies (UPDATE with the same check in using and with check), and DELETE only if the app deletes files. 6) Write a test that signs in as user B and tries to upsert, update and move a file in user A's folder, and expects an error. Do not run the migration until I approve it.

More prompts are in the security review prompt guide.

Check what your live app exposes with LeakCheck

LeakCheck scans any live app URL or public GitHub repo, read only, wherever it is hosted. On a live app that uses Supabase, it finds the key your app ships and checks whether that key can read your tables, alongside its other checks for exposed files and leaked keys. On a public repo it looks for committed keys, including a service role key.

Its limits, honestly: it never sends writes, so it never uploads or overwrites a file, and it cannot prove that one user is unable to replace another user's file. It also cannot see your storage policy definitions. The policy set and the cross user test above are how you prove that.

Sources

Questions

Why does my Supabase Storage upload fail with new row violates row-level security policy?

Every upload inserts a row into storage.objects, and that table has RLS turned on. With no INSERT policy, or with an INSERT policy whose WITH CHECK fails for the path you send, the upload is rejected. In our test the usual causes were a missing INSERT policy, a path whose first folder was not the user's id, a path with no folder at all, a different bucket than the policy names, and a request made with the anon key while the policy was for authenticated.

Why does upload work but upsert: true fails?

upsert: true (the x-upsert header) runs INSERT ... ON CONFLICT DO UPDATE on storage.objects, and Postgres checks more policies for that. In our test, upsert: true on a brand new path needed INSERT and SELECT policies, and overwriting an existing file needed INSERT, SELECT and UPDATE. With only an INSERT policy, plain uploads worked and every upsert failed with the same new row violates message. update() behaved like an upsert.

Can one user overwrite another user's file in Supabase Storage?

Only if your policies allow it, but bucket only policies do. In our test, INSERT, SELECT and UPDATE policies that checked only bucket_id let a second user upsert onto the first user's path. The file content was replaced, owner_id switched to the second user, and the second user could also list the first user's folder. Policies that also check the first folder against auth.uid() blocked the overwrite and the cross folder move.

Does a public bucket need RLS policies for uploads?

Yes. Public only affects downloads. In our test, a public bucket served the file at its public URL with no SELECT policy, but uploads still needed the INSERT policy, upsert still needed SELECT and UPDATE, and the anon key could not upload. Supabase's bucket docs say access control is still enforced for uploading, deleting, moving and copying.

Why does createSignedUrl return Object not found for a file that exists?

Creating a signed URL reads the object row, so it needs a SELECT policy that matches the file. In our test, with only an INSERT policy, createSignedUrl on a file the user had just uploaded returned Object not found, and list() on the same folder returned an empty array with no error. Adding the owner folder SELECT policy fixed both.

Why does my storage policy fail with operator does not exist: text = uuid?

storage.foldername(name) returns text and auth.uid() returns uuid, so comparing them directly fails when you create the policy. Cast one side: (storage.foldername(name))[1] = (select auth.uid())::text, or compare with (select auth.jwt()->>'sub'), which is the form Supabase's access control examples use.