Storage RLS lives on storage.objects; the folder path is the boundary

Policies on storage.objects check bucket_id and the path segments from storage.foldername(name) against your tables. Private URLs never render in an img tag.

Storage

· Chapter

37

·

3

min read

The answer. Files live in object storage, but every object has a metadata row in storage.objects, and that table is where access control happens: policies with bucket_id, the full path in name, and owner_id (the uploader's user id). The design move is to put the authorization data into the path: <tenant>/<ticket>/<random>/<filename>, so a policy can split it with storage.foldername(name) (a 1-based array of segments) and check each segment against your own tables, which runs their policies as the same user and gives tenant isolation for free. Choose bucket visibility by revocability, not convenience: a public bucket serves fast, CDN-cached URLs that anyone who ever saw them keeps forever; a private bucket needs a signed URL or an authenticated download per view, which is what "removed from the tenant" should mean.

The pattern.

create policy "tenant members upload attachments" on storage.objects for insert to authenticated with check (
  bucket_id = 'attachments'
  and rls_helpers.has_tenant_access((storage.foldername(objects.name))[1])
  and exists (select 1 from public.tickets t where t.id = (storage.foldername(objects.name))[2]::bigint)   -- runs tickets' RLS
);
create policy "tenant members read attachments" on storage.objects for select to authenticated
  using (bucket_id = 'attachments' and rls_helpers.has_tenant_access((storage.foldername(objects.name))[1]));
const path = `${tenant}/${ticketId}/${crypto.randomUUID()}/${file.name}`;
const { data, error } = await supabase.storage.from("attachments").upload(path, file, { upsert: false });

Watch out.

  • Write objects.name, not name, once the policy joins a table that also has a name column; the ambiguity picks a column for you.
  • A storage.objects policy cannot select from storage.objects (Postgres refuses the recursion); a quota like "one file per ticket" goes through a security definer helper.
  • list() returning [] means no SELECT policy, not an empty folder, and it is true for public buckets too. Upserts need SELECT and UPDATE, not only INSERT.

Related: storage-buckets-do-not-migrate-with-the-database · signed-urls-and-image-transforms-for-private-files · a-policy-that-reads-another-table-runs-that-tables-policies