# Storage access control

> Control who can upload, read, list and delete files in Supabase Storage on SupaHost with RLS policies on storage.objects. Includes per-user and per-team folder patterns.

Source: https://supahost.app/docs/storage/access-control · Updated 2026-11-11

Storage permissions are Row Level Security policies on the `storage.objects` table in your project's database. Every object is a row with a `bucket_id`, a `name` (its full path) and an `owner_id`. When a request reaches the Storage API, it runs as the caller's Postgres role (`anon` or `authenticated`), and your policies decide what that role may do.

RLS is enabled on `storage.objects` already. With no policies, nothing is allowed except for server code using the `service_role` key, which bypasses RLS.

## Which policy each operation needs

| What the client does | Policy needed on `storage.objects` |
|---|---|
| `upload()` a new file | `insert` |
| `upload()` with `upsert: true`, or `update()` | `insert`, `select` and `update` |
| `download()` from a private bucket, `createSignedUrl()` | `select` |
| `list()` a folder | `select` |
| `move()` | `select` and `update` |
| `copy()` | `select` on the source, `insert` on the destination |
| `remove()` | `select` and `delete` |
| Read a public bucket's file by URL | Nothing. Public URLs skip RLS |

Creating, updating or deleting buckets checks policies on `storage.buckets`. Most apps manage buckets with SQL or the `service_role` key instead, so they don't need those policies.

## Helper functions

Storage ships helpers for working with paths in policies:

- `storage.foldername(name)` returns the folders as a text array. For `team-7/2026/a.pdf` it returns `{team-7,2026}`.
- `storage.filename(name)` returns `a.pdf`.
- `storage.extension(name)` returns `pdf`.

Postgres arrays start at 1, so `(storage.foldername(name))[1]` is the top-level folder.

## Pattern: each user owns a folder

The most common layout puts every user's files under a folder named with their user id: `avatars/<user id>/avatar.webp`.

```sql
create policy "Users can upload their own avatar"
on storage.objects for insert to authenticated
with check (
  bucket_id = 'avatars'
  and (storage.foldername(name))[1] = (select auth.uid())::text
);

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

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

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

Wrapping `auth.uid()` in `(select ...)` lets Postgres evaluate it once per query instead of once per row, which matters when a folder holds many files.

If `avatars` is a public bucket, anyone can still view the images by URL. These policies only control writes, listings and authorised downloads.

## Pattern: files shared by a team

In an invoicing app, invoices belong to a team, and every member should be able to read them. Store files as `invoices/<team id>/<invoice>.pdf` and check membership in your own table.

```sql
-- Your own table of who belongs to which team
create table public.team_members (
  team_id uuid not null,
  user_id uuid not null references auth.users (id) on delete cascade,
  role text not null default 'member',
  primary key (team_id, user_id)
);
create index on public.team_members (user_id);

create policy "Team members can read team invoices"
on storage.objects for select to authenticated
using (
  bucket_id = 'invoices'
  and exists (
    select 1 from public.team_members m
    where m.user_id = (select auth.uid())
      and m.team_id::text = (storage.foldername(name))[1]
  )
);

create policy "Team admins can upload invoices"
on storage.objects for insert to authenticated
with check (
  bucket_id = 'invoices'
  and exists (
    select 1 from public.team_members m
    where m.user_id = (select auth.uid())
      and m.team_id::text = (storage.foldername(name))[1]
      and m.role = 'admin'
  )
);
```

Remember to enable RLS on `team_members` itself and give it policies too, otherwise the Data API exposes it. See [Row Level Security](/docs/database/row-level-security).

## Pattern: anyone can browse a public bucket

A public bucket serves files by URL, but `list()` still needs a select policy. If your app shows a gallery by listing a folder, allow it explicitly:

```sql
create policy "Anyone can list recipe photos"
on storage.objects for select to anon, authenticated
using (bucket_id = 'recipe-photos');
```

Only add this if listing really should be public. Listing reveals every file name in the bucket.

## Using `owner_id`

When an authenticated user uploads a file, Storage sets `owner_id` to their user id (as text). You can write policies on it instead of on folders:

```sql
create policy "Owners can delete their files"
on storage.objects for delete to authenticated
using (bucket_id = 'attachments' and owner_id = (select auth.uid())::text);
```

Folder-based rules are usually easier to reason about, because the path tells you who the file belongs to before it exists. That's what an insert policy needs.

## Server code and the service_role key

Requests with the `service_role` key skip every policy. Use it on your server or in Edge Functions for admin jobs: generating files, cleaning up, migrating. Never ship it to a browser or mobile app. If server code acts on behalf of a user, pass that user's access token instead so RLS still applies. See [Keeping keys safe](/docs/security/api-key-safety).

## Testing your policies

In Studio's SQL editor you can check a policy as a specific user before trying it from the app:

```sql
begin;
set local role authenticated;
select set_config('request.jwt.claims', '{"sub":"8f9c2d1e-0000-4000-8000-000000000001","role":"authenticated"}', true);

select name from storage.objects where bucket_id = 'invoices';
rollback;
```

The query returns only the rows that user's select policies allow.

## Related

- [Storage 403: new row violates row-level security policy on upload](/kb/storage-rls-upload-403)
- [Folder operations and hierarchical policies in Storage](/kb/storage-folder-operations)
- [Private files still accessible after logout](/kb/private-files-still-accessible)
- [RLS performance best practices](/kb/rls-performance)
