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 whatgetPublicUrl()builds. - Everything else:
upload,list,download,createSignedUrl,move,copyandremove. These run as the caller's role and check policies onstorage.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
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 );Allow listing only if you want it. A
selectpolicy foranonlets 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 );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.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;