Storage and Realtime

Protect Storage objects

Use private buckets, predictable object paths, storage RLS policies, upload limits, and signed URLs without trusting a caller-provided path.

9 minute lesson

~~~

Supabase Storage keeps object metadata in PostgreSQL: every file in a bucket has a row in storage.objects, and RLS policies on that table control API access. You protect files with the same tool you protect rows.

Start with private buckets unless every file is intentionally public:

insert into storage.buckets (id, name, public, file_size_limit, allowed_mime_types)
values ('avatars', 'avatars', false, 1048576, array['image/png', 'image/jpeg']);

The size and MIME limits reject a 2 GB “avatar” before it costs you anything. Restrict size and content type at the bucket, but still inspect untrusted uploads where application safety depends on the file contents — a content type header is a claim, not proof.

Ownership lives in the path

Never trust a caller-provided path. Put the authenticated user ID in a controlled path and verify ownership in policy:

create policy "users manage own avatar"
on storage.objects for all
to authenticated
using (
  bucket_id = 'avatars'
  and (storage.foldername(name))[1] = auth.uid()::text
)
with check (
  bucket_id = 'avatars'
  and (storage.foldername(name))[1] = auth.uid()::text
);

storage.foldername(name) splits the object path into its folders; the policy demands the first folder equal the caller’s user ID. Uploads then look like:

const { error } = await supabase.storage
  .from('avatars')
  .upload(`${user.id}/avatar.png`, file, { upsert: true })

Run the proof with your two test users. Ada uploads to <ada-id>/avatar.png: success. Ada replaces her own object with upsert: true: success. Ada uploads to <grace-id>/avatar.png: a 403 with “new row violates row-level security policy”. Ada requests Grace’s private object: denied. If any of those four checks surprises you, fix the policy, not the test.

To share a private object without opening the bucket, mint a signed URL on demand:

const { data } = await supabase.storage
  .from('avatars')
  .createSignedUrl(`${user.id}/avatar.png`, 3600)
// data.signedUrl works for one hour, then expires

The common mistake is the shortcut: making the bucket public “for now” because signed URLs felt like work. Public means every object is readable by anyone who has or guesses the path, with no policy consulted. Undoing it later means assuming every path already leaked.

Lesson completed

Take this course offline

Get every free book and course as PDF and EPUB files.

Get the download library →