Supabase Storage Buckets: The Public-by-Default Mistake
Supabase storage security in plain English: how public buckets expose user photos, IDs, and receipts, how storage RLS works, and how to check your buckets.
Your database rows aren't the only thing your app stores. If users upload anything — profile photos, receipts, ID documents, screenshots — those files live in Supabase Storage. And storage has its own security model, separate from your tables. You can lock down every row in your database and still leave a folder of users' uploaded IDs sitting in the open.
That's the trap with Supabase storage security: a bucket can be public by default, and a public bucket means anyone with a link can fetch the files inside it. For a photo-sharing app that might be fine. For anything holding personal documents, it's a quiet leak waiting to be found.
This post explains how public and private buckets differ, how storage policies actually protect files, when to use signed URLs, and how to check your own buckets in a few minutes.
⚡ TL;DR
- Supabase Storage organizes files into buckets, and a bucket can be public or private. A public bucket serves any file to anyone who has its URL — no login, no check.
- Private buckets are protected by storage RLS policies (the same Row Level Security idea as your tables) and you hand out access to specific files using signed URLs that expire.
- The mistake that leaks data is putting sensitive uploads — IDs, receipts, private photos — in a public bucket, or a private bucket with no real policy. Check every bucket that holds personal files.
How Supabase storage actually works
Supabase Storage keeps your files in containers called buckets. You might have a avatars bucket, a receipts bucket, an id-documents bucket. Each bucket has one setting that changes everything about its security: it is either public or private.
A public bucket serves every file inside it to anyone who knows the URL. There's no login check, no per-user rule — the file is on the open web, much like an image hosted on any public website. If someone gets the link, they get the file.
A private bucket does not serve files openly. To read a file from a private bucket, a request has to pass a storage policy — a rule you write that says who's allowed to access what. Without an explicit, valid request, the file stays locked.
The reason this catches people out is the same reason missing RLS catches people out: in a demo, a public bucket "works" perfectly. Uploads succeed, images display, the feature looks done. Nothing on screen tells you the bucket is also serving those files to the entire internet — and with file uploads, what's exposed is often the most sensitive data your app holds.
When public is fine — and when it's a leak
Public buckets aren't evil. They're the right choice for truly public content: marketing images, public blog header photos, a company logo. Files that you'd happily put on any web page belong in a public bucket, and using one saves you the overhead of signing every request.
The leak happens when personal or sensitive files end up in a public bucket. Think about what your users actually upload:
- Profile photos that users expect to be private to their account
- Receipts and invoices with names, addresses, and purchase history
- Government IDs, passports, or selfies for verification
- Screenshots or documents attached to private messages
Put any of those in a public bucket and you've published them. Someone reading your app's network requests sees the file URLs go by, and those URLs work for anyone, forever. This is exactly the shape of the lesson from the Tea app breach: an unsecured legacy storage bucket exposed roughly 72,000 images, including around 13,000 selfies and government IDs. That incident was on Firebase, not Supabase, but the root cause is identical across both platforms — sensitive user uploads sitting in storage that wasn't actually locked down. We break that case down in open storage buckets: the #1 Firebase leak.
The takeaway: the question isn't "are public buckets bad?" It's "is this particular bucket, holding this particular kind of file, supposed to be readable by the whole internet?" For anything personal, the answer is no.
How storage RLS policies protect private files
Private buckets are protected by the same mechanism as your database tables: RLS (Row Level Security) policies. Supabase Storage keeps a record of every file as a row in an internal table, and you write policies against those rows to control access.
A typical policy says something like "a user can only read files inside a folder named after their own user ID." In practice, you store each user's uploads under a path like receipts/{their-user-id}/..., and the policy checks that the folder matches the logged-in user:
-- Let users read only files inside their own user-ID folder
create policy "Users read own receipts"
on storage.objects for select
using (
bucket_id = 'receipts'
and auth.uid()::text = (storage.foldername(name))[1]
);
The important part is that the policy ties the file to a specific user, the same way a good table policy ties a row to its owner. Without a policy like this, a private bucket either blocks everyone (so your app breaks) or, if it's actually public, serves everyone (so it leaks). The policy is what makes "only the right user gets this file" true.
If you've read Supabase RLS explained, this will feel familiar — it's the same per-user filtering idea, applied to files instead of rows.
Signed URLs: how to share a private file safely
If a bucket is private, how does a legitimate user actually see their own file? The answer is a signed URL.
A signed URL is a temporary, single-purpose link to one file in a private bucket. Your app generates it on demand for a user who's allowed to see the file, and the link expires after a time window you choose — a minute, an hour, a day. After it expires, the link is dead.
// Generate a link to one private file that works for 60 seconds
const { data } = await supabase
.storage
.from('receipts')
.createSignedUrl('user-123/receipt-april.pdf', 60);
// data.signedUrl is a temporary link you can show this user
This is the safe pattern for sensitive uploads: keep the bucket private, protect it with a policy, and hand out short-lived signed URLs to the specific users who should see specific files. The file is never on the open web; access is granted one link at a time, and that access expires. Contrast that with a public bucket, where the URL is permanent and works for anyone — no expiry, no check, no take-backs.
How to check your own buckets
You can audit your storage in a few minutes from the dashboard and your browser.
- Open the Supabase dashboard → Storage. Look at the list of buckets. Each one is labeled public or private. Ask yourself, for every public bucket: would I be comfortable with these exact files on the open web? If the bucket holds anything personal, that's a red flag.
- For private buckets, check the policies. A private bucket with no real policy is either broken or not as private as you think. Confirm each one has a policy that ties files to a specific user, not a blanket rule that lets everyone through.
- Test a file URL as an outsider. Grab a file URL from your app's network traffic (browser dev tools → Network tab while you use the app), then open it in a private/incognito window where you're not logged in. If a sensitive file loads, that file is public to anyone.
- Look for sensitive uploads in the wrong place. If verification IDs or receipts are coming back through the same kind of always-public URL as your logo, they're in a public bucket and need to move.
Not sure if your app has this exact issue?
Run a free, read-only scan of your live app — no install, results in under a minute.
Scan my app free →The fix
If you find sensitive files in a public bucket, here's the remediation path.
- Make the bucket private. In the dashboard, switch the bucket from public to private. This stops the open-web serving immediately.
- Add a storage policy that ties each file to its owner, like the per-user-folder example above. Store uploads under a user-ID folder so the policy has something to match on.
- Switch your app to signed URLs for displaying those files. Wherever your code currently uses a permanent public URL, generate a short-lived signed URL instead.
- Verify with the outsider test. Re-run the incognito check on a file URL. It should now fail for a logged-out visitor and only work through a fresh signed URL.
If you're using an AI builder, you can prompt it like this: "Make the receipts and id-documents buckets private. Add storage RLS policies so a user can only access files in a folder named after their own auth.uid(). Update the app to use short-lived signed URLs instead of public URLs for these files." Then confirm with the outsider test yourself — don't assume it's done.
FAQ
Are Supabase storage buckets public by default?
When you create a bucket you choose public or private, and it's easy to end up with a public bucket either by selecting it or because an AI builder defaulted to it to make uploads work in the preview. A public bucket serves its files to anyone with the URL, so any bucket holding personal files should be private with policies and signed URLs instead.
What's the difference between a public URL and a signed URL?
A public URL points to a file in a public bucket. It's permanent and works for anyone, forever — no login, no expiry. A signed URL points to a file in a private bucket, is generated for a specific user on demand, and expires after a time window you set. Use public URLs only for truly public content and signed URLs for anything sensitive.
Does enabling RLS on my tables also protect my storage?
No. Table RLS and storage policies are separate. Locking down your database rows does nothing for files in a public bucket — storage has its own access model. You have to secure buckets and write storage policies in addition to your table RLS.
The bottom line
Supabase Storage is a second front you have to secure on purpose. A public bucket puts every file inside it on the open web, which is fine for a logo and a disaster for a folder of users' IDs. Keep sensitive uploads in private buckets, protect them with per-user storage policies, and serve them through short-lived signed URLs. The Tea app showed how badly an unsecured bucket of personal files can go — and an attacker can find an open bucket in minutes by watching your app's network traffic. The point is to find it first, and to keep checking, because every new upload feature your AI adds is a new bucket that might be open.
Find your gaps before an attacker does.
Is My Site Hackable? scans your deployed app for the exact issues in this article — exposed keys, missing RLS, open buckets — and tells you what's real and what's a false alarm.
Run a free scan →