Is a public Supabase Storage bucket safe for user files?
By StackSiren · Published
A public Supabase Storage bucket serves any file to anyone who has (or can guess) the object URL. Use private buckets with storage.objects policies and short-lived signed URLs for anything that is not meant to be world-readable.
Public buckets are fine for marketing images and truly shareable media. They are the wrong default for ID scans, invoices, chat attachments or anything tied to an account. Only test Storage projects you own.
Public vs private buckets
Supabase documents two access models:
| Public bucket | Private bucket (default) | |
|---|---|---|
| Download / serve | Anyone with the object URL; CDN-friendly | Requires auth (JWT) that passes RLS, or a signed URL |
| Upload / delete / move | Still gated by RLS policies on storage.objects |
Same — RLS on every mutating operation |
| Typical URL | …/storage/v1/object/public/[bucket]/[path] |
No lasting public URL; use createSignedUrl or authenticated GET |
Public means read is open. It does not mean anonymous users can upload unless you also wrote an open insert policy — and you should not.
Lovable and Bolt apps often create an avatars or uploads bucket during scaffolding. Check Dashboard → Storage → each bucket's Public toggle before you ship. Related table RLS: Lovable Supabase RLS, Bolt Supabase RLS checklist.
storage.objects policies
RLS on storage.objects is what gates uploads and, for private buckets, authenticated downloads. Supabase's access-control docs: with no policies, uploads are denied by default. Start tight, then open only what you need.
Owner-scoped private bucket (replace private-docs and adjust the path convention):
-- Authenticated users may upload only under their own folder
create policy "users_upload_own_folder"
on storage.objects
for insert
to authenticated
with check (
bucket_id = 'private-docs'
and (storage.foldername(name))[1] = (select auth.jwt() ->> 'sub')
);
-- Same users may read only their own objects
create policy "users_read_own_objects"
on storage.objects
for select
to authenticated
using (
bucket_id = 'private-docs'
and (storage.foldername(name))[1] = (select auth.jwt() ->> 'sub')
);
-- Optional: allow owners to delete their own files
create policy "users_delete_own_objects"
on storage.objects
for delete
to authenticated
using (
bucket_id = 'private-docs'
and (storage.foldername(name))[1] = (select auth.jwt() ->> 'sub')
);
Avoid using (true) / with check (true) on Storage the same way you avoid it on tables — see USING (true) policies. Never put the service_role key in the browser to "just make Storage work"; that bypasses RLS entirely (service_role exposed).
Public marketing bucket example (read is open because the bucket is public; still lock writes):
create policy "avatars_authenticated_upload"
on storage.objects
for insert
to authenticated
with check (bucket_id = 'avatars');
Do not add a broad select policy with using (true) on a private bucket thinking it is "like public" — that lets any role the policy targets list and download every object they can reach with the anon or user JWT.
Signed URLs
For private objects, Supabase recommends either:
- An authenticated GET to
…/storage/v1/object/authenticated/[bucket]/[path]with the user's JWT, or - A time-limited signed URL from
createSignedUrl
Sign on the server (Route Handler, Edge Function, trusted backend). Example with the JS client using a user session or a server client that already checked auth:
// Server-side only — after you verified the caller owns this path
const { data, error } = await supabase.storage
.from("private-docs")
.createSignedUrl(`${userId}/invoice-42.pdf`, 60 * 15); // 15 minutes
if (error) throw error;
// data.signedUrl — hand to the browser for this download only
Notes from Supabase's serving docs:
- Signed URLs use a Storage-specific signing key, separate from Auth JWT rotation quirks — they remain valid until expiry.
- Keep TTLs short for sensitive files; do not mint day-long URLs for ID documents.
getPublicUrlonly makes sense on public buckets. Calling it on a private bucket does not unlock the object for strangers.
Test
Only on projects you own. Replace the project ref, bucket and path.
1. Confirm public vs private
# If the bucket is public and the object exists, this returns the file bytes
curl -sI "https://YOUR-PROJECT.supabase.co/storage/v1/object/public/private-docs/user-a/secret.pdf"
Pass (private bucket): 400-class / non-200 without a usable body for strangers.
Fail: 200 and content-type for a file that should be private — the bucket is public or the path is wrong.
2. Signed URL works for you, not forever
Mint a signed URL while signed in as the owner, open it in a private window, then wait past expiry and retry. Expect success then failure.
3. Second user cannot read the first user's object
With two test accounts, upload as user A under A/…, then as user B call download / authenticated GET on A's path. Expect denial. Same discipline as table RLS — pair with how to test website security before launch on launch day.
Our Supabase RLS checker helps on table policies for projects you own; always re-check Storage buckets in the Dashboard as well.
Folder layout that matches policies
Policies that key off (storage.foldername(name))[1] = auth.uid() only work if the client uploads to that layout. Enforce the path in both places:
// Client upload — path must start with the signed-in user id
const path = `${session.user.id}/${file.name}`;
const { error } = await supabase.storage.from("private-docs").upload(path, file, {
upsert: false,
contentType: file.type,
});
Reject client-supplied paths that escape the folder (no ../, no empty first segment). For team buckets, store team_id in a table and compare with a policy that joins membership — do not rely on “security through obscure object names” in a public bucket.
Migrating an accidental public bucket
If production already has private files in a public bucket:
- Create a new private bucket (or toggle public off only after you understand cache/CDN behaviour for existing URLs).
- Copy objects server-side with the service role (Edge Function or trusted backend) — never from the browser.
- Update the app to
createSignedUrl/ authenticated download. - Rewrite or remove old
getPublicUrllinks in emails, CMS and mobile apps. - Keep the old public paths only for assets that must stay world-readable; delete or move the rest.
- Rotate any long-lived URLs you emailed; treat them as shared secrets that leaked.
Public-bucket objects may sit on CDN caches. After you lock a bucket down, expect old URLs to keep working until cache expiry unless you purge — plan cutover accordingly.
Lovable / Bolt notes
Scaffolded Storage is convenient and easy to leave on Public. In the Supabase Dashboard linked from Lovable or Bolt:
- Open Storage → each bucket → confirm Public is off for user documents.
- Open Authentication / SQL and list policies on
storage.objects(select * from pg_policies where tablename = 'objects';in the SQL editor). - Re-test signed-out access after every schema or bucket change — same habit as table RLS.
Deep Scan ($49 one-off, or on Pro) goes further than a passive look at your marketing site when you need Storage and API behaviour checked on a property you own. Start with the free scan either way.
FAQ
When is a public bucket OK? Profile photos, blog images and other assets you would happily put on a CDN. Not for documents that identify a person or account.
Does making a bucket private block uploads? No. Uploads were already gated by RLS. Private mainly changes how downloads work.
Can I hide a public-bucket URL by not linking it? No. Guessable paths, referrer leakage, logs and shared links all expose public objects. Assume the URL is public.
Do I need RLS if the bucket is private?
Yes. Private buckets still evaluate storage.objects policies for authenticated access. Without policies, clients cannot upload or download as intended.
Is the anon key enough to read private files? Only if your policies allow that role for that object — which they should not for user documents. Prefer authenticated policies + signed URLs.
What about Deep Scan? A free StackSiren scan checks what is visible from outside on a site you own. For Storage and RLS depth beyond passive checks, Deep Scan is $49 one-off (or included on Pro). Start with the free scan, then tighten buckets and policies.
Fix guides for this topic
Platform checklists: Lovable · Bolt