Skip to content
← Back to the blog
securityinfrastructure

Supabase Storage: public vs private buckets and signed URLs

When a Supabase Storage bucket should be public or private, how signed URLs work, which storage.objects policies you need and how to fix a wrong bucket.

By Devonauts Team5 min read
Leer en español →
Stacked black document folders with colored tabs next to a calculator and a small plant

In Supabase Storage, a public bucket lets anyone who has a file's URL download it, with no login and no policy check. A private bucket checks Row Level Security policies on every download, and you share files either through an authenticated download or a signed URL that expires. Use public buckets only for files you'd be happy to see on a billboard (logos, product photos, public avatars). Everything that belongs to a person, such as invoices, contracts, ID documents and private uploads, goes in a private bucket.

This guide explains what each setting really controls, the policies a private bucket needs, how to use signed URLs without leaking them, and how to fix a bucket that was created with the wrong setting.

What does "public" actually change?

Less than people expect. According to the Supabase Storage bucket docs, marking a bucket public bypasses access control only for retrieving and serving files. Uploading, deleting, moving and copying are still controlled by your policies. So:

Public bucketPrivate bucket (default)
DownloadAnyone with the URLNeeds a policy match or a signed URL
Upload, delete, moveNeeds a policyNeeds a policy
URL lastsForeverSigned URLs expire
Typical useLogos, product images, public avatarsDocuments, receipts, user uploads

A public file's address follows this format, which is easy to guess once you know one file's path:

https://<project-ref>.supabase.co/storage/v1/object/public/<bucket>/<path>

If paths contain user IDs or sequential numbers, anyone who sees one URL can try others. That's why "it's public but nobody knows the link" is not protection.

Supabase also notes that public buckets perform better because they're cached differently, which is one more reason to keep truly public assets there and nothing else.

How do I check which of my buckets are public?

In the dashboard, open Storage and look at each bucket's settings; public buckets are labelled. Or run this in the SQL editor:

select id, name, public, file_size_limit, allowed_mime_types
from storage.buckets;

For every bucket marked true, open a few files and ask: would I be fine if a stranger saw this? AI builders often create one public bucket called something like uploads and put everything in it.

What policies does a private bucket need?

Storage access runs through RLS on the storage.objects table. A common, safe layout is one folder per user, named after their user ID, for example invoices/2f1c.../march.pdf. Then:

create policy "Users upload into their own folder"
on storage.objects for insert
to authenticated
with check (
  bucket_id = 'invoices'
  and (storage.foldername(name))[1] = (select auth.jwt() ->> 'sub')
);

create policy "Users read their own files"
on storage.objects for select
to authenticated
using (
  bucket_id = 'invoices'
  and (storage.foldername(name))[1] = (select auth.jwt() ->> 'sub')
);

storage.foldername(name) splits the path into folders, and [1] is the first one. Add matching update and delete policies only if users should be able to replace or remove files. The Storage access control guide points out that an upload with "upsert" (overwrite if it exists) needs select and update permission as well as insert.

For files shared within a team, use the organization ID as the first folder and check membership instead of the user ID; our multi-tenant guide shows the helper function.

How do signed URLs work?

A signed URL is a normal link with a token that expires. Anyone holding it can download the file until then, without logging in. You create it from code that is allowed to read the file:

const { data, error } = await supabase.storage
  .from('invoices')
  .createSignedUrl(`${userId}/march.pdf`, 60 * 10); // expires in 10 minutes

The second argument is the lifetime in seconds. Practical rules:

  1. Keep lifetimes short. Minutes for a download button, not days. Generate a fresh link each time the user clicks.
  2. Don't store signed URLs in the database. Store the file path and sign it when needed. A stored link either expires and breaks, or lives so long that it's effectively public.
  3. Create them where the policy check happens. If your user's own session creates the link, RLS decides whether they may. If a server creates it with the secret key, the server must check who is asking first, because the secret key skips RLS.
  4. Treat a signed URL like a temporary password. Don't put it in emails that get forwarded, or in analytics events and logs.

For uploads from the browser without exposing write access broadly, Supabase also offers signed upload URLs (createSignedUploadUrl), which allow one upload to one path.

Should I limit file size and type?

Yes. Each bucket can have its own maximum file size and list of allowed MIME types, so a bucket for avatars can accept only image/png, image/jpeg and image/webp up to a few megabytes. Set these in the bucket settings or when creating the bucket. It stops someone using your storage to host large files and reduces the risk of HTML or script files being served from your domain. If uploads are failing rather than leaking, our guide to file uploads failing in a Lovable app covers the usual errors.

How do I fix a bucket that should have been private?

  1. List what's in it and decide which files are private.
  2. Create a new private bucket (or switch the existing one to private) and add the storage.objects policies above.
  3. Change the code that builds URLs. Replace getPublicUrl calls with createSignedUrl or authenticated downloads.
  4. Update stored URLs. If your tables store full public URLs, store paths instead and sign them on demand.
  5. Flip the setting and test with a logged-out browser: the old public link should no longer return the file.
  6. Think about who saw what. If personal documents sat in a public bucket, consider whether that's a data exposure you need to handle. This isn't legal advice; for personal data, check with a lawyer.

Keep public assets such as logos in a separate public bucket, so you never have to mix both kinds in one.

Quick answers

Can I make just one file public in a private bucket? Not with a setting. Give that file a long-lived signed URL, or better, move it to a public bucket.

Does a public bucket let people list all files? Listing goes through the API and needs a select policy on storage.objects. Downloading by URL doesn't. Don't add broad select policies to public buckets.

Are Storage files included in Supabase backups? No. Database backups include file metadata, not the files. See our guide to Supabase backups.

Why does my private image not show in an <img> tag? The browser requests it without your session token. Use a signed URL as the src.

Want your buckets checked?

The free App Health Check looks at what your live app exposes from outside. A Supabase security review goes through every bucket, policy and URL-building path in the code. You can also ask us a question about your setup.

Still stuck with your app?

Run the free health check on your app's URL, or get the $299 App Rescue Audit: a senior engineer reviews the code and sends you a written report in 2 business days.