Now-Next

SaaS architecture

Supabase Storage RLS: why uploads fail, measured

Which policies on storage.objects a Supabase upload, upsert, download and delete need, measured on PostgreSQL 17 with the SQL the Storage API runs.

Chris van Eijk, Now-Next · · 8 min read

A Supabase Storage upload that fails with new row violates row-level security policy for table "objects" is missing a policy on storage.objects. Which one depends on the call. We rebuilt the storage schema from Supabase’s own migrations on PostgreSQL 17.11, ran the SQL that the Storage API runs for each operation, and recorded what each policy set allowed. A plain upload needs only an INSERT policy. An upload with upsert: true also needs SELECT, and UPDATE as soon as the file already exists; with policies that only check the bucket, user A then overwrote user B’s file and became its owner. A bucket marked public changed nothing for row-level security.

Which policy does each Storage operation need?

OperationPolicies on storage.objects it neededWithout them
UploadINSERTnew row violates row-level security policy for table "objects"
Upload with upsert: true over an existing fileINSERT, SELECT and UPDATEthe same error; without UPDATE, … (USING expression) …
Upload with upsert: true, new fileINSERT and SELECT; without UPDATE not tested on Storagethe same error
Upload without upsert over an existing filerefused whatever the policiesduplicate key value violates unique constraint "idx_objects_current_version"
Download from a private bucket, list, signed URLSELECTnothing found
DeleteDELETE and SELECTDELETE 0, no error in SQL
Create a bucketINSERT on storage.bucketsnew row violates row-level security policy for table "buckets"

Supabase’s access control guide says the first two rows itself: “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.” This page does not cover delete; it points to the client reference for the per-operation list, which we did not check. In our run delete needed SELECT too.

The setup

PostgreSQL 17.11 in a throwaway container, with Supabase’s roles and auth.uid() as in our other Supabase measurements, and the storage schema from the supabase/storage repository (commit c826a70, 16 October 2026): storage.buckets, storage.objects with its owner and owner_id columns and its unique index, and storage.foldername(). Users A and B, and anon.

We did not run the Storage API itself. We read its source and ran its SQL. Per request, the API sets the role and the JWT claims with set_config(…, true), the same as PostgREST. For an upload, it first runs a test insert as the caller’s role and rolls it back. Without upsert that test is a plain INSERT INTO storage.objects (…) VALUES (…). With upsert it is INSERT … ON CONFLICT (bucket_id, name COLLATE "C") WHERE archived_at IS NULL DO UPDATE SET … RETURNING *. If the test passes, the real write runs as service_role. The API fills owner and owner_id from the JWT’s sub. A download from a private bucket first looks the object up with a SELECT as the caller, and a delete runs a test as the caller before the real delete as service_role. For upload, upsert and delete the row-level security outcome is decided by that test, which an upload runs before it starts and again when it completes; for download, list and signed URLs by the caller’s own query. Those statements are what we measured.

One upload with upsert: true, as the Storage API's source runs it
  1. As the caller's roleSets the role and the JWT claims for the transaction, then tests the insert with ON CONFLICT … DO UPDATE … RETURNING * and rolls it back.
    Row-level security decides here: INSERT, SELECT, and UPDATE if the file exists
  2. The APIStores the file.
    No policy involved
  3. As the caller's role, againInside the transaction that completes the upload, the same test runs again, now with the stored file's metadata, and is rolled back to a savepoint.
    The same policies, checked again; if they now fail, the stored file is deleted
  4. As service_roleWrites the row in storage.objects, in the same transaction, with owner_id from the JWT's sub (and owner, if that sub is a UUID).
    Bypasses row-level security

From the source of supabase/storage at commit c826a70. We ran the SQL of steps 1 and 3 ourselves; we did not run the API.

Why does upload with upsert fail when upload works?

Because the upsert form asks for the row back and may update it. With an INSERT policy bucket_id = 'avatars', a plain upload gave INSERT 0 1. The upsert failed with new row violates row-level security policy for table "objects" for a new file and for an existing one. INSERT and UPDATE without SELECT failed the same way: an INSERT … ON CONFLICT DO UPDATE checks the SELECT policies as well. With INSERT, SELECT and UPDATE, the upsert of A’s own existing file worked.

Can one user overwrite another user’s file?

Yes, if the policies only check the bucket. With INSERT, SELECT and UPDATE policies that all said bucket_id = 'avatars', user A uploaded B/b.png with upsert. The test passed (INSERT 0 1, the existing row replaced and owner_id set to A), so the API goes on to overwrite the file, and the row records A as its owner. B could do the same to A. It is the UPDATE policy that decides an overwrite; the INSERT check only sees the new row. With the same bucket-wide DELETE and SELECT policies, A could also delete B’s file; that follows from the delete case below, we did not test it separately. Without upsert, A’s upload to an existing name failed on the unique index instead. That duplicate key error is what stops an overwrite there, and the API also refuses an overwrite without upsert in its own code.

A bucket-wide policy is the right choice for a bucket every signed-in user may write to. For anything per user, the policy has to say whose file it is.

How do you keep each user’s files in their own folder?

With the folder name in every policy, as in Supabase’s examples:

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.uid()::text) );

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

create policy "Users update their own folder" 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) );

create policy "Users delete in their own folder" on storage.objects
for delete to authenticated
using ( bucket_id = 'avatars'
  and (storage.foldername(name))[1] = (select auth.uid()::text) );

With these four, A could upload to A/x.png and to a subfolder A/sub/x.png, upsert over A’s own file, list A/, and delete A’s own file. An upload to B’s folder failed with new row violates, and so did an upsert over B’s file. An upload to the root of the bucket, x.png, failed too: storage.foldername('x.png') returns an empty array, so [1] is null and the check is never true. Listing B’s folder found nothing, and deleting B’s file gave DELETE 0. Supabase’s own example uses (select auth.jwt()->>'sub') instead of auth.uid()::text; it gave the same results. On a public bucket these policies do not make files private: the read policy then only governs listing and signed URLs, and anyone with the path can still download the file.

The other way to tie a file to a user is the owner column, owner_id = (select auth.uid()::text). Because the API fills owner_id from the JWT, an upload by A or B with INSERT and SELECT policies on it worked. anon failed: the policies are to authenticated, and without a sub the API leaves owner_id null, so it could not match either. An upsert with only those two policies failed with the (USING expression) form of the message: there was no UPDATE policy.

Does a public bucket need a SELECT policy?

Not for its public URL, and marking a bucket public does not open storage.objects. With public = true and no SELECT policy, a SELECT on the bucket’s objects as A, B or anon found nothing; as service_role it found the file. Downloads work anyway: in the API’s source the public URL is served as service_role, which bypasses row-level security, and so is download() from a public bucket on the authenticated route. Under its public-read example, Supabase’s guide notes that such a policy “is not needed for public buckets, as they are already publicly accessible”. That holds for downloading. In the API’s source, listing (through storage.search(), which we did not rebuild) and createSignedUrl() look the object up as the caller; our SELECT as A, B and anon found nothing, so both need a SELECT policy, public bucket or not.

Why does delete do nothing?

Because the delete’s WHERE reads name and bucket_id, and rows the caller cannot select are not visible to it. With only a DELETE policy bucket_id = 'avatars', the delete as the caller gave DELETE 0, with and without RETURNING. With a SELECT policy added, DELETE 1. In SQL that is no error. We did not run the API; in its source, the single-object delete route then answers Access denied. A direct DELETE on storage.objects, even as a superuser, failed with Direct deletion from storage tables is not allowed. Use the Storage API instead.: the API sets a setting that this check looks for, and a manual delete does not.

Reproduce it

setup.sql creates the Supabase roles, auth.uid(), and the parts of the storage schema the cases need, copied from the migrations. run.sql runs every case as A, B and anon, each in a transaction that it rolls back. run.sh does both in a throwaway container.

What we did not measure

The Storage API itself: which HTTP status and message the client gets for each SQL outcome. storage.search(), which the API uses to list; we listed with a plain SELECT. Buckets with versioning. And the table owner: on Supabase that is supabase_storage_admin, here it was our install user; neither is involved in a request.

Sources: Supabase, Storage access control, read on 19 October 2026; the storage schema and the Storage API’s SQL from supabase/storage at commit c826a70, read on 19 October 2026. All results are our own, on PostgreSQL 17.11 in a throwaway container, on 19 October 2026.

Let's talk

What are we building?

One conversation is enough to know whether we fit. Tell us what you have in mind — we will tell you how fast it can happen, and whether we are the right people for it.

Start the conversation