File storage

env.STORAGE is your app's object store for user uploads, generated files, and assets. It's an object-storage bucket with a put/get/list/delete API, and a built-in way to serve files publicly over HTTP.

Reading & writing

// api/upload.js
export default {
  async fetch(request, env, ctx) {
    const user = await env.AUTH.getUser(request);
    if (!user) return new Response("Unauthorized", { status: 401 });

    const body = await request.arrayBuffer();
    const key = user.id + "/avatar.png";

    await env.STORAGE.put(key, body, {
      httpMetadata: { contentType: "image/png" },
    });

    return Response.json({ key });
  },
};
  • put(key, value, opts?) · get(key, opts?) · head(key) · delete(key | key[])
  • list(opts?) — keys are returned relative to your app's space.
  • createMultipartUpload / resumeMultipartUpload for large files.
Keys are scoped to your app automatically — you can't read or overwrite another app's files or the platform's managed objects.

Serving files publicly

Mark an object public when you store it, and it's served at /files/<key> on your app's domain — no route code needed:

await env.STORAGE.put(key, body, {
  httpMetadata: { contentType: "image/png" },
  customMetadata: { public: "true" },
});
// now reachable at  https://your-app.com/files/<key>

Objects without public: "true" aren't served by the /files route — fetch those in your own handler with env.STORAGE.get after an auth check, or hand the browser a signed URL (below).

Public files are cached hard at the edge for speed, so if you overwrite the same key, call env.STORAGE.invalidate(key) afterwards to force viewers to get the new version:

await env.STORAGE.put("logo.png", newBytes, {
  httpMetadata: { contentType: "image/png" },
  customMetadata: { public: "true" },
});
await env.STORAGE.invalidate("logo.png"); // clear the cached old copy

If you write to a fresh key each time (e.g. a hash or timestamp suffix), you don't need this — a new key is never stale.

Signed URLs for private files

To let a browser load a private object directly — without routing the bytes through your own handler on every request — generate a short-lived signed URL. It carries an expiring signature, so you can hand it to an authenticated user after your own access check and they can fetch the file straight from storage:

// in your route, after checking the user may see this file:
const url = await env.STORAGE.signedUrl("invoices/2026-07.pdf", 300); // valid 300s
return Response.json({ url });
// browser fetches url directly; the link stops working after 5 minutes

The signature covers the exact key and expiry, so it can't be altered or reused for another object. Responses are sent Cache-Control: private, no-store so the file is never held in a shared cache. Use this for per-user documents, receipts, or media; keep genuinely public assets on the /files route instead.

Signed URLs are the right tool for "this user may see this file for a little while." They don't revoke early — pick a short TTL. For anything world-readable, mark it public and use /files/<key>.

Large uploads

For very large user uploads, an assistant can request a direct upload URL with the request_upload_url MCP tool rather than streaming the bytes through a route. See Limits & quotas for file-size caps.