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.
Supabase Storage keeps file metadata in PostgreSQL. Every file in a bucket has a row in storage.objects, and RLS policies on that table control who can reach it through the API. So you protect files with the same tool you just used to protect rows. Nothing new to learn, just a new table.
Start with private buckets, unless every file is meant to be 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 limit is in bytes, so this bucket accepts 1 MB at most. Together with the MIME list it rejects a 2 GB “avatar” before it costs you anything. Be careful though: a content type header is a claim, not proof. When your app’s safety depends on what is inside a file, inspect the bytes on the server as well.
Ownership lives in the path
Never trust a path the caller sends you. Put the signed-in user’s ID in a path you control, and verify ownership in the 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 that the first folder equal the caller’s user ID. Uploads then look like this:
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 file with upsert: true: success. Ada uploads to <grace-id>/avatar.png: a 403 with “new row violates row-level security policy”. Ada downloads Grace’s private file: denied. If any of those four checks surprises you, fix the policy, not the test.
Share without opening the bucket
Sometimes you need to show a private file to someone, or to an <img> tag. Don’t make the bucket public for that. Mint a signed URL on demand, a link that carries its own expiring token:
const { data } = await supabase.storage
.from('avatars')
.createSignedUrl(`${user.id}/avatar.png`, 3600)
// data.signedUrl works for one hour, then expires
The second argument is the lifetime in seconds. After an hour the link is dead, and whoever copied it gets an error.
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, and no policy is consulted. Undoing it later means assuming every path already leaked. Start private. It is much easier to open a bucket than to close one.
Lesson completed