# 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.

Source: https://supahost.app/kb/public-bucket-upload · Updated 2026-11-11

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:

```js
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):

   ```sql
   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:

   ```sql
   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:

   ```js
   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:

```sql
select id, public from storage.buckets;
```

## Related

- [Storage 403: new row violates row-level security policy](/kb/storage-rls-upload-403)
- [Buckets](/docs/storage/buckets)
- [Storage access control](/docs/storage/access-control)
- [Serving and downloading files](/docs/storage/serving-files)
