Supabase storage bucket is public: how to lock down uploaded files
By Obscura Security · Updated · Supabase, Lovable, Bolt, Next.js
Short answer
Anyone with the URL can download files in a public Supabase bucket, with no login. For IDs, invoices or any private upload, make the bucket private, add storage.objects policies that tie each file to its owner, and show files through short-lived signed URLs.
If your app lets people upload files, such as profile photos, invoices or ID documents, and the Supabase bucket holding them is public, every file is available to anyone who has its URL.
Why this happens
When you create a bucket in Supabase, there’s a “public bucket” toggle. It’s easy to switch on because it makes images “just work” in the browser: you get a URL, you put it in an <img> tag, done. AI tools do this by default, and often use one bucket for everything.
Public URLs look like this:
https://YOUR-PROJECT.supabase.co/storage/v1/object/public/BUCKET/path/to/file.pdf
No key, no login. If the app also uses guessable file names (invoice-1042.pdf, passport.jpg) or puts the URL in an API response, strangers can fetch private files.
The second problem is on the policy side. Supabase Storage uses Row Level Security on a table called storage.objects. A private bucket with no policies blocks users from everything, which tempts people to add a policy like using (bucket_id = 'documents'). That lets every logged-in user read every file in the bucket.
How to check your own app
Do these on your own project.
- In the Supabase dashboard, open Storage. Buckets marked Public are open to anyone with a file link.
- For each public bucket, ask whether the files in it are ones you’d put on your homepage. If not, it should be private.
- Open one file’s public URL in a private browser window where you’re not logged in. If it loads, anyone can see it.
- Check whether file names can be guessed, and whether your app returns file URLs inside other users’ records.
- Check whether anonymous users can list the files. With your anon or publishable key (never your secret key):
curl -X POST "https://YOUR-PROJECT.supabase.co/storage/v1/object/list/YOUR-BUCKET" \
-H "apikey: YOUR_ANON_KEY" \
-H "Authorization: Bearer YOUR_ANON_KEY" \
-H "Content-Type: application/json" \
-d '{"prefix":"","limit":100}'
If you get a list of file names back, a policy is allowing anonymous listing.
- In the dashboard, look at Storage → Policies (or Authentication → Policies, depending on the dashboard version) for policies on
storage.objects. Any policy with onlybucket_id = '...'and no check on the owner is too broad.
The fix
1. Make private files live in a private bucket
In Storage, open the bucket’s settings and turn off the public option. Public URLs for that bucket stop working at that point, which is what you want for private files. If you have a mix of files, make two buckets: avatars (public, if you wish) and documents (private).
2. Save each file under the owner’s ID
Upload with the user’s ID as the first folder in the path. Policies can then check it:
const path = `${user.id}/${crypto.randomUUID()}.pdf`
const { error } = await supabase.storage
.from('documents')
.upload(path, file)
3. Add storage policies that tie files to their owner
-- Users can upload only into their own folder
create policy "Users upload to own folder"
on storage.objects for insert
to authenticated
with check (
bucket_id = 'documents'
and (storage.foldername(name))[1] = (select auth.uid())::text
);
-- Users can read only their own files
create policy "Users read own files"
on storage.objects for select
to authenticated
using (
bucket_id = 'documents'
and (storage.foldername(name))[1] = (select auth.uid())::text
);
-- Users can replace their own files
create policy "Users update own files"
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
);
-- Users can delete their own files
create policy "Users delete own files"
on storage.objects for delete
to authenticated
using (
bucket_id = 'documents'
and (storage.foldername(name))[1] = (select auth.uid())::text
);
storage.foldername(name) splits the file path into its folders, and [1] is the first one. Because every file lives under <user id>/..., the policy compares that folder with the logged-in user.
4. Show files with signed URLs
For a private bucket, ask for a link that expires, at the moment you need to show the file:
const { data, error } = await supabase.storage
.from('documents')
.createSignedUrl(path, 60) // valid for 60 seconds
if (data) window.open(data.signedUrl)
A signed URL works for whoever has it until it expires, so keep the time short and don’t store signed URLs in your database. Store the file path instead and create a new link each time. The user must pass the select policy for the call to succeed.
5. Test from outside
Repeat the logged-out check on a file URL, and the curl list request. Then sign in as one test user and try to read a path belonging to another test user. All of these should fail.
6. Assume the old files were seen
If private files sat in a public bucket, the old URLs were reachable for as long as the bucket was public. Check your storage and CDN logs where available, and decide whether any customers need to be told.
Mistakes to avoid
- Policies that only check
bucket_id. That gives every logged-in user every file. - Leaving upload open to anonymous users. A policy with
to anonor no role on insert lets anyone fill your storage. - Saving the public URL in the database. After you make the bucket private it breaks, and before that it was the leak. Save the path.
- Long-lived signed URLs. A link valid for a year behaves like a public file.
- Mixing public and private files in one bucket. Separate buckets make mistakes cheaper.
Common questions
- What is the difference between a public and a private bucket in Supabase?
- In a public bucket, anyone with the file's URL can download it without logging in, and access policies are not applied to those downloads. In a private bucket every download needs either an authorised user or a signed URL. Uploading, deleting and listing files still depend on storage policies in both cases.
- I made the bucket private and my images stopped loading. Why?
- The public URLs your app saved no longer work. Create a signed URL when you display each file, or add a select policy and download it as the logged-in user.
- Are my files safe if nobody knows the file names?
- No. Names leak through your app's responses, shared links, browser history and logs, and with a public bucket the URL is all anyone needs. Don't rely on obscure names.
- Should profile pictures be in a public bucket?
- It's reasonable if you're happy for anyone to see them. Keep documents, invoices, ID scans and anything personal in a separate private bucket, so a mistake with one doesn't expose the other.
Read next
Written by Obscura Security, an Australian practice that checks web apps for problems like this one. If you want a second pair of eyes on yours, email [email protected].