Security · 3 min read · 21 October 2026

One token, one action: uploads that can't roam the bucket

A CMS uploads files straight from the browser to a storage Worker. What stops that endpoint from being an open bucket is a token that lives five minutes and names the one thing it may touch.

A cartoon key with a timer for a head unlocking one open drawer of a filing cabinet, while every other drawer stays padlocked
5 min
lifetime of every storage token
1
action per token: upload, delete or list
0
file types taken from the browser's own claim

01 · The problem

Big files can't go through the app server.

The CMS runs on Vercel, which caps a request body at 4.5 MB. Videos are far bigger than that, so the browser uploads straight to a Cloudflare Worker in front of an R2 bucket. A Worker that accepts uploads from a browser is a public endpoint. Without a check, anyone who finds the URL can upload, list or delete.

02 · A token per action

The CMS signs a fresh token for every single action.

After the session check, the CMS server signs a JWT. The issuer is the CMS, the audience is the storage Worker, and it expires in 300 seconds. It carries exactly one grant: upload into one folder, delete one key, or list one prefix. A leaked token can't roam the bucket.

type Grant =
  | { scope: "upload"; folder: "images" | "files" | "videos" }
  | { scope: "delete"; key: string }
  | { scope: "list"; prefix: string };

export function mintStorageToken(userId: number, grant: Grant) {
  return new SignJWT({ ...grant })
    .setProtectedHeader({ alg: "HS256" })
    .setSubject(String(userId))
    .setIssuer("cms")
    .setAudience("storage-worker")
    .setIssuedAt()
    .setExpirationTime("300s")
    .sign(secret);
}

03 · The Worker checks everything

The Worker trusts the token, not the request.

It verifies the signature, issuer, audience and expiry. Then the scope has to match the route: an upload token sent to the delete route gets a 403. Then the target has to match: the folder, key or prefix comes from the token, not from the request body.

const { payload } = await jwtVerify(token, secret, {
  algorithms: ["HS256"],
  issuer: "cms",
  audience: "storage-worker",
});
if (payload.scope !== scope) {
  throw new AuthError(403, `Token scope "${payload.scope}" cannot be used here`);
}

04 · Don't trust the browser on file types

file.type is whatever the browser says it is.

The Worker works out the content type from the file extension and ignores what the client sends. Extensions that can carry script, such as .svg and .html, are blocked. Each folder has its own size limit: 10 MB for images, 25 MB for files, 200 MB for videos. A key must sit directly under an allowed folder, and anything containing .. is rejected.

05 · One way in

The codebase has exactly one upload path.

Feature code may not render its own <input type="file">. Every upload goes through one asset picker, which asks for a token and then talks to the Worker. The Worker's file rules and the CMS's file rules live in two files that must change together, and the project rules say so in writing.

06 · What it costs

One extra round trip, and one secret to look after.

Each upload first asks the CMS for a token, one small request before a file that can be 200 MB. The token is signed with the same secret as the CMS session, so rotating it also logs every editor out. Next time I'd give storage its own secret.

Next storyPublish without a rebuild