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.
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. Forteam-7/2026/a.pdfit returns{team-7,2026}.storage.filename(name)returnsa.pdf.storage.extension(name)returnspdf.
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.
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.
-- 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.
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:
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:
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.
Testing your policies
In Studio's SQL editor you can check a policy as a specific user before trying it from the app:
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.