Why can't I upload to or list my public bucket?

A public bucket only makes files readable by URL. Uploads, listing, moves and deletes still go through RLS on storage.objects, so they need policies like any private bucket.

Marking a bucket public does one thing: anyone with a file's public URL can download it. It doesn't open the bucket for writing or listing. Those operations still check Row Level Security on storage.objects.

What you see

The bucket recipe-photos is public and images load fine from their public URLs, but:

await supabase.storage.from('recipe-photos').upload('tarts/plum.jpg', file)
// error: new row violates row-level security policy

const { data } = await supabase.storage.from('recipe-photos').list('tarts')
// data: []  (but the folder has files)

Why it happens

Storage has two ways to read a file:

  • The public URL, /storage/v1/object/public/<bucket>/<path>. For public buckets this skips RLS entirely. That's what getPublicUrl() builds.
  • Everything else: upload, list, download, createSignedUrl, move, copy and remove. These run as the caller's role and check policies on storage.objects.

Permissions each operation needs:

Operation Policies needed on storage.objects
upload insert
upload with upsert: true insert, select, update
list, download, createSignedUrl select
move select, update
copy select on the source, insert on the destination
remove select, delete

With no policies, list() returns an empty array rather than an error, the same way a table with RLS returns no rows.

How to fix it

  1. Let signed-in users upload into the bucket (narrow it to their own folder if they shouldn't touch each other's files):

    create policy "Signed-in users add recipe photos"
    on storage.objects for insert to authenticated
    with check (
      bucket_id = 'recipe-photos'
      and (storage.foldername(name))[1] = (select auth.uid())::text
    );
    
  2. Allow listing only if you want it. A select policy for anon lets anyone enumerate every file name in the bucket, which a public URL alone doesn't. Often listing for the owner is enough:

    create policy "Users list own recipe photos"
    on storage.objects for select to authenticated
    using (
      bucket_id = 'recipe-photos'
      and (storage.foldername(name))[1] = (select auth.uid())::text
    );
    
  3. Even better, don't list Storage at all: save each file's path in your own table (for example recipes.photo_path) when you upload, and query that table.

  4. Show public files with getPublicUrl, which doesn't make a request:

    const { data } = supabase.storage.from('recipe-photos').getPublicUrl('8f1c.../plum.jpg')
    // data.publicUrl → https://app-one.acme.supahost.space/storage/v1/object/public/recipe-photos/8f1c.../plum.jpg
    

On SupaHost

Public object URLs are served without an API key, so they work in <img> tags and emails. All other Storage requests need the apikey header, which supabase-js sends for you. Check which buckets are public with:

select id, public from storage.buckets;

Updated 11 November 2026 · View as Markdown · Something wrong or missing? Tell us