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

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.

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