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 in | Use it for | |
|---|---|---|
GET /storage/:id | The owner, or anyone if the object is public | An avatar, an attachment in your own UI |
GET /storage/:id/url | The owner — returns a presigned URL | A large download you do not want proxied |
GET /storage/share/:token | Anyone holding the token | A 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.