Skip to content

Public bucket or private bucket: decide once, per bucket, on purpose

A public bucket serves every object to anyone who guesses a key, forever. A private one costs you a signing step and a cache problem. Here is how to choose, and why mixing them in one bucket goes wrong.

Supabase Storage5 min readships at docs/solutions/supabase-storage/public-vs-private-buckets.md

Tags: supabase · storage · security · caching · buckets · architecture

Someone flips a bucket to public because an image was not loading. The image loads. Six months later a security review finds that every invoice PDF, every uploaded passport scan and every private export in that bucket is readable by anyone who can construct a URL: no session, no token, no log entry.

Nothing was hacked. The bucket was doing exactly what "public" means.

What the flag actually does

Public bucket: every object is served at a stable, permanent URL to anyone who requests it. No authentication, no expiry, no revocation: you can delete the object, but you cannot un-share a URL that is already in someone's browser history, a Slack channel, or a search index.

Private bucket: objects are only reachable through a signed URL, minted by code that decided you were allowed, valid for as long as you chose.

The choice is not "convenience versus paranoia". It is a product decision about each class of object, and the honest test is one question:

If this URL appeared in a public spreadsheet tomorrow, would anything bad happen?

If the answer is no (a marketing image, a logo, a public blog cover) public is correct and simpler and faster. If it is anything else, private.

Why public is genuinely better when it applies

Being private is not free:

  • Caching. A signed URL changes every time you mint it, so the browser and the CDN treat every render as a new resource. A list of fifty avatars re-downloads all fifty on every page load. Public URLs are stable and cache properly, in the browser, in the CDN, and in any layer between.
  • Rendering. A page containing signed URLs cannot be cached for longer than the URLs live, which pushes it towards dynamic rendering.
  • Cost and latency. Signing is a round trip per object per render, and it happens on your server.

So: public for things that are public, deliberately, and take the caching benefit.

Why mixing them in one bucket always goes wrong

The failure mode at the top of this document exists because one bucket held both kinds of object. Someone needed one image to be public and had exactly one lever: a bucket-wide flag.

One bucket per access model:

-- private: everything belonging to one user
insert into storage.buckets (id, name, public, file_size_limit, allowed_mime_types)
values ('uploads', 'uploads', false, 26214400, array['image/png','image/jpeg','application/pdf'])
on conflict (id) do update set public = excluded.public;

-- public: things that are public by nature
insert into storage.buckets (id, name, public, file_size_limit, allowed_mime_types)
values ('public-assets', 'public-assets', true, 5242880, array['image/png','image/jpeg','image/webp','image/svg+xml'])
on conflict (id) do update set public = excluded.public;

Now "make this public" is a decision about which bucket to write to, made per object at upload time, by code you can review, not a flag someone flips at 2am while debugging.

The in-between cases

An avatar shown next to public comments. Public. It is already visible to everyone who reads the page; a signed URL buys you nothing except a caching problem. Put it in the public bucket.

An avatar in a private team app. Private. The image is a small piece of information about who works where.

A generated share link: "anyone with this link can view". Neither. That is a feature: a row in your database with a token you can revoke, a view count, and an expiry you control. A signed URL is a poor substitute because it cannot be revoked before it expires and you cannot see who used it.

Invoices, exports, anything with a customer's name on it. Private, always, and with a short expiry.

Guessability is not a security model

The usual defence of a public bucket is that keys contain a uuid, so nobody can guess them. Two problems.

First, URLs leak by travelling: they are pasted into tickets, forwarded in email, captured by browser extensions, logged by proxies, and indexed when they end up on a page. Nothing in a public bucket can be un-shared.

Second, it is a single mistake away from being enumerable: a listing endpoint, a directory index, a sitemap, a bug that returns a neighbouring key. Private buckets fail closed when that mistake happens; public buckets fail open.

Use uuids in keys regardless. Just do not count them as access control.

Public serving in practice

Once a bucket is public, an object's URL is stable and cacheable:

const url = publicUrl(key); // https://<project>.supabase.co/storage/v1/object/public/public-assets/<key>

Set a long cacheControl when you upload, and make the key immutable: include a hash or a uuid so a new version means a new key. Overwriting a key and hoping caches notice is how a logo change takes a week to appear for some users.

Auditing what you have

select id, public, file_size_limit, allowed_mime_types
from storage.buckets
order by public desc, id;

Any bucket with public = true should hold only objects you would be happy to find in a public spreadsheet. If one of them holds user uploads, that is an incident: move the objects to a private bucket, update the keys you stored, and assume the old URLs are still out there, because they are.

bun run verify checks that the private bucket has not been flipped, for exactly this reason. The flip is one click in a dashboard and invisible in a diff, which is also why the bucket is created by a migration rather than by hand.