# 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.

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**:

```sql
-- 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:

```ts
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

```sql
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.

---

Agentic Boilerplate: A Next.js repo your agent already knows. Free during launch, then $99 once.

- Site map for agents: https://agenticboilerplate.com/llms.txt
- Public API: https://agenticboilerplate.com/openapi.json
- Contact: agenticstudio@gmail.com
