Supabase storage buckets: public or private?
A public bucket hands every file to anyone with its URL. Here is when that is fine, how to lock the rest down per user, and how to prove it from outside.
Free scan: GET and HEAD requests only, after you verify you own the domain. The two-account cross-user test is the $29 Proof Report. Updated 25 Oct 2026
In short
- ✓“Public” on a bucket means anyone with a file’s URL can download it, with no policy check. Uploads, listing and deletes still go through policies on
storage.objects. - ✓Use public buckets only for files you would put on a billboard: logos, product images, published avatars.
- ✓For user files, use a private bucket, a folder per user, policies on
storage.foldername(name), and signed URLs. - ✓Test as two users: can user A list or download user B’s files? Can a stranger list your buckets?
01What “public bucket” actually means in Supabase
Supabase Storage keeps file metadata in two tables, storage.buckets and storage.objects, and access is controlled by RLS policies on them, just like your own tables (Supabase docs).
| Action | Public bucket | Private bucket |
|---|---|---|
| Download by URL | Anyone with the URL, no check | Policy check, or a signed URL |
| List files | Policy on storage.objects | Policy on storage.objects |
| Upload, update, delete | Policy on storage.objects | Policy on storage.objects |
So a public bucket is not automatically listable, but its file URLs are guessable more often than people think: avatars/USER_ID.png, invoices/2026-10.pdf. If a file name can be predicted, a public bucket serves it to anyone.
02Step 1: list your buckets
select id, public, file_size_limit, allowed_mime_types
from storage.buckets
order by public desc;
For each public bucket, ask: would I be fine with every file in here showing up in a search engine? If not, make it private (update storage.buckets set public = false where id = 'documents';) and switch the app to signed URLs.
Also set limits. file_size_limit and allowed_mime_types stop a stranger from uploading a 5 GB file or an HTML page to your bucket:
update storage.buckets
set file_size_limit = 5242880, -- 5 MB
allowed_mime_types = array['image/png','image/jpeg','image/webp']
where id = 'avatars';03Step 2: review the policies on storage.objects
select policyname, cmd, roles, qual, with_check
from pg_policies
where schemaname = 'storage' and tablename = 'objects';
Red flags:
bucket_id = 'documents'as the whole condition, forselect. Every signed-in user can read every document. Anyone can sign up, so that is nearly public.- Roles
{public}or{anon}oninsert. Strangers can upload into your bucket. - An
insertpolicy without an owner check inwith_check. User A can drop files into user B’s folder. - A
selectpolicy onstorage.bucketsforanon. Strangers can list every bucket name. Usually harmless, sometimes revealing.
04Step 3: owner-folder policies that actually hold
Store each user’s files under a folder named after their user id, for example documents/2b7f…/contract.pdf. Then compare the first folder with auth.uid():
create policy "own files: read"
on storage.objects for select to authenticated
using (bucket_id = 'documents'
and (storage.foldername(name))[1] = (select auth.uid())::text);
create policy "own files: upload"
on storage.objects for insert to authenticated
with check (bucket_id = 'documents'
and (storage.foldername(name))[1] = (select auth.uid())::text);
create policy "own files: update"
on storage.objects for update to authenticated
using (bucket_id = 'documents'
and (storage.foldername(name))[1] = (select auth.uid())::text)
with check (bucket_id = 'documents'
and (storage.foldername(name))[1] = (select auth.uid())::text);
create policy "own files: delete"
on storage.objects for delete to authenticated
using (bucket_id = 'documents'
and (storage.foldername(name))[1] = (select auth.uid())::text);
In the client, build the path from the session, never from user input: `${user.id}/${file.name}`. Upserting a file that already exists needs select and update as well as insert.
05Step 4: serve private files with signed URLs
const { data, error } = await supabase.storage
.from('documents')
.createSignedUrl(`${user.id}/contract.pdf`, 60); // seconds
// data.signedUrl works for 60 seconds, then expires
The select policy decides who may create the link, and the expiry limits how long a forwarded link works. Keep expiries short for sensitive files and create links when the user clicks, not when the page loads.
06Step 5: test it as two users and a stranger
- Sign in as user B and upload a test file to their folder.
- Sign in as user A and try
supabase.storage.from('documents').list(B_USER_ID). Expect an empty list. - As A, try
download(`${B_USER_ID}/test.txt`). Expect an error. - Signed out, open
https://YOUR_REF.supabase.co/storage/v1/object/public/documents/B_USER_ID/test.txt. Expect an error unless the bucket is meant to be public. - Signed out, check the bucket list:
curl "https://YOUR_REF.supabase.co/storage/v1/bucket" \
-H "apikey: YOUR_PUBLIC_KEY" -H "Authorization: Bearer YOUR_PUBLIC_KEY"
# [] is what you want; a list of buckets means anon can see them
Delete the test file afterwards. For the same idea applied to tables, see the RLS checker guide.
07Common Supabase storage mistakes
- Making a bucket public to fix an error. AI builders often do this when an image won’t load. The image loads, and so does every other file in the bucket. Fix the select policy or use a signed URL instead.
- Trusting the file name from the client. If the path comes from a form field, user A can write
B_USER_ID/avatar.png. Build the path from the session and enforce it inwith_check. - Policies for
authenticatedwith no owner check. “Signed-in users can read documents” means anyone who signs up can read them. - Long-lived signed URLs stored in the database. A one-year link saved in a row is a public link with extra steps. Create links on demand.
- Using the service role key to upload from the browser. It skips every storage policy, and it ships to every visitor. See what to do if it leaked.
- No size or type limits. Without
allowed_mime_types, a public bucket can end up hosting someone else’s HTML or scripts on your project’s domain.
08What Preflight checks in Supabase Storage
The free scan asks your project for its bucket list with the public key, signed out, and reports whether strangers can list your buckets and how many are marked public. It does not list or download files. The $29 Proof Report runs step 5 for you: test user B uploads a marked canary file, test user A tries to list and download it, and the public URL is tried too. The canary is labelled so you can spot and remove it.
?Questions people ask
Is a public Supabase bucket safe?
For files meant for everyone, such as logos and product images, yes. Anyone with a file’s URL can download it without any policy check, so never put user documents or private uploads there.Can people list files in a public bucket?
Only if a select policy onstorage.objectsallows it. “Public” controls downloads by URL, not listing. Check your policies anyway: many apps add a broad select policy.How do I make files private per user in Supabase?
Use a private bucket, store files under a folder named after the user id, and add policies comparing(storage.foldername(name))[1]withauth.uid(). Serve files with signed URLs.Do signed URLs expire?
Yes, after the number of seconds you pass tocreateSignedUrl. Anyone holding the link can use it until then, so keep expiries short.Why does my upload fail after adding policies?
Most often the path does not start with the user id, or an upsert also needs select and update policies. Check the path your client builds.
What an outside scan can't tell you
Can one user read another user's data?
Preflight signs in as two test accounts you create and tries to read one account's rows and files from the other. That cross-user test is the $29 Proof Report for Supabase apps. Start with the free scan: it checks your live app from outside with GET and HEAD requests only.