Supabase Storage · 42501

Storage upload blocked by RLS — policies on storage.objects

Luca Urti

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

Why is my Supabase Storage upload rejected by a row-level security policy?

Supabase Storage is a PostgreSQL table. Every file is a row in storage.objects, and uploads are INSERTs governed by exactly the same Row Level Security as any other table — so an upload fails when no policy on storage.objects has a with check expression that is true for the bucket and path you are writing to. Creating the bucket in the dashboard does not create policies for it. The path matters as much as the bucket: the convention is to prefix each object with the owner's user id, and to write the policy so that the first path segment must equal auth.uid().

Bucket, path, and the folder convention

The bucket is not part of the object's name — bucket_id is its own column, and name holds only the path inside that bucket. storage.foldername(name) returns that path's folder segments, so (storage.foldername(name))[1] = auth.uid()::text reads as “the first folder inside the bucket is this user's id”. With that policy on bucket documents, uploading to <uid>/invoice.pdf works and uploading to <someone-else>/invoice.pdf does not. Getting this wrong is easy in the other direction too: a file uploaded to the bucket root has no folder at all, foldername(name)[1] is null, and the policy rejects it — which reads as “storage is broken” rather than “this file needs a folder”.

Without the folder condition, a policy that only checks bucket_id lets any authenticated user write anywhere in the bucket — including over another user's file, since an upsert to the same path replaces it.

Public and private are a property of the bucket, not the policy

A public bucket serves every object over an unguessable-looking but entirely enumerable URL with no authentication at all, and RLS on storage.objects does not apply to those reads. That is correct for avatars and product images. It is not correct for anything a user uploaded expecting privacy — identity documents, invoices, exports, medical or contract files — and “the URL contains a UUID” is not access control, because URLs end up in logs, referrer headers, chat messages and third-party analytics.

For private files, keep the bucket private and hand out signed URLs with a short expiry. The signature is the access control, and it expires.

The fix

per-user folder policies

-- Upload: only into a folder named after your own user id
create policy "users upload to their own folder"
  on storage.objects
  for insert
  to authenticated
  with check (
    bucket_id = 'documents'
    and (storage.foldername(name))[1] = auth.uid()::text
  );

-- Read: same condition, or the bucket is private for nothing
create policy "users read their own files"
  on storage.objects
  for select
  to authenticated
  using (
    bucket_id = 'documents'
    and (storage.foldername(name))[1] = auth.uid()::text
  );

-- Private bucket + signed URL, rather than a public bucket:
--   supabase.storage.from('documents').createSignedUrl(path, 60)

The fix that works and costs you the database

Flipping the bucket to public makes both the upload error and every subsequent download problem disappear at once, which is why it is the most common resolution. It also removes authentication from every object already in that bucket, retroactively — the files uploaded last month become reachable too. If the bucket holds anything a user would not post publicly, this converts a configuration error into a data breach, and nothing in the dashboard flags it afterwards because a public bucket is a legitimate setting.

Whether the trap is already in your repo is a question you can answer

Sentris reads the SQL and the client code, so it reports the shortcut above where it was actually taken — a service_role key in a browser bundle, a policy that is using (true), a table with RLS switched off. A scan needs no account and no card. How often it is wrong is measured and published.

Scan my app