Serving files

You need: storage composed.

The bucket is never public

The object model is why a path never reaches R2, how a share works is the revocable alternative, sharing files is the walkthrough, and the reference has the routes.

Nothing is served directly out of R2. Every read goes through your Worker — which is what lets public mean this particular object rather than this whole bucket.

Three ways a file reaches somebody

Who gets inUse it for
GET /storage/:idThe owner, or anyone if the object is publicAn avatar, an attachment in your own UI
GET /storage/:id/urlThe owner — returns a presigned URLA large download you do not want proxied
GET /storage/share/:tokenAnyone holding the tokenA link you send somebody

The download route carries no auth guard, on purpose

A public object has to be readable without a session, so authorization moves into the handler where it can see which object was asked for.

HEAD on the same path does require a session — a metadata probe is not a download, and treating them the same would leak existence.

Visibility is authorization, not a bucket setting

PATCH /storage/<id>
{ "visibility": "public" }

private is the owner alone. public is anyone with the id.

The id is a UUID precisely because it appears in every route path — a sequential integer would let anybody count your files and probe for neighbors.

An active content type is neutralized on serve

The stored content type is whatever R2 confirmed. The download response sets the headers that stop a browser executing it — content type options, a content disposition, a restrictive content-security policy.

A user-uploaded SVG or HTML file is a script you are hosting unless something says otherwise on the way out. That is what those headers are.

Range requests work

Which is what makes a large file seekable — a video scrubbing, a resumed download.

Presigned versus proxied

Presigned hands the client a URL and gets your Worker out of the path. Cheaper, and the right answer for a large file.

It cannot be revoked, only expired. That is the trade, and it is the whole reason shares are a table rather than presigned URLs.

Proxied keeps every read behind your own authorization, which is what you want when the decision is finer-grained than this object is public.

Doing your own authorization

Nothing stops a route of your own reading an object and deciding for itself:

app.get("/reports/:id", requireAuth(), requireEntitlement("pro"), async (c) => {
  // your rules, then hand back the object
});

Your route is a peer of the capability’s, not a wrapper around it. The capability’s own routes stay scoped to the caller; yours can be scoped to anything.

ESC