Storage: Buckets and Policies
The idea in one sentence
Section titled “The idea in one sentence”Supabase Storage is S3-compatible object storage organized into buckets, and access to every object is enforced by ordinary Row Level Security policies on a Postgres table — the same mechanism you already know from the RLS module, not a separate permission system.
Buckets: public and private containers
Section titled “Buckets: public and private containers”A bucket is a top-level container for files, similar to an S3 bucket — you might have an avatars bucket for profile pictures and a public-assets bucket for marketing images. A bucket can be marked public, meaning anyone with the object’s URL can read it with no authentication, or private, meaning access is controlled per-request.
Uploading, downloading, and reading files from supabase-js all operate on a named bucket:
// Upload a file into a path inside the bucketconst { data, error } = await supabase.storage .from('avatars') .upload('user123/photo.png', file);
// Download the raw file bytesconst { data: fileData } = await supabase.storage .from('avatars') .download('user123/photo.png');
// Get a public URL — only meaningful for a public bucketconst { data: publicUrlData } = supabase.storage .from('avatars') .getPublicUrl('user123/photo.png');
// Get a time-limited signed URL — for a private bucketconst { data: signedUrlData } = await supabase.storage .from('avatars') .createSignedUrl('user123/photo.png', 60);getPublicUrl just builds a URL string — it does not check anything, so calling it on a private bucket’s object gives you a URL that will still be rejected. createSignedUrl is what you actually want for private content: it mints a URL, valid for the number of seconds you pass in (60 above), that embeds a signed token granting temporary access to that one object.
What actually enforces access: Row Level Security
Section titled “What actually enforces access: Row Level Security”Here is the fact worth internalizing: Storage does not have its own bespoke permission language. Every uploaded object’s metadata — its bucket, its path, its owner, its content type — lives as a row in a real Postgres table, storage.objects. Access control is just Row Level Security policies on that table, written with the exact same using and with check syntax you already use for any other table in this course.
A common pattern is to let a user manage only files under their own folder prefix, keyed by their auth.uid():
create policy "Users can upload their own avatar"on storage.objectsfor insertto authenticatedwith check ( bucket_id = 'avatars' and (storage.foldername(name))[1] = auth.uid()::text);
create policy "Users can read their own avatar"on storage.objectsfor selectto authenticatedusing ( bucket_id = 'avatars' and (storage.foldername(name))[1] = auth.uid()::text);With policies like these, a file uploaded to avatars/user123/photo.png is only writable and readable by the user whose id is user123 — because name (the object’s full path) is checked against auth.uid() on every request, exactly like RLS on any other table gates rows by the requesting user.
flowchart LR
client["Client upload/download request"] --> api["Storage API"]
api --> objects[("storage.objects metadata row: bucket_id, name, owner")]
objects --> rls{"RLS policy: using / with check against auth.uid()"}
rls -->|allowed| data["Underlying object data returned or written"]
rls -->|denied| reject["403 rejected"]