Signed URLs
Generate URLs that work for a limited time, even for users who aren't signed in. Both uploads and downloads are supported.
const url = await env.storage.presign("covers/hello.png");The default method is GET and the default expiry is 300 seconds.
await env.storage.presign(key, { method: "GET", ttl: 60 }); // downloadable for 1 minute
await env.storage.presign(key, { method: "PUT", ttl: 3600 }); // uploadable for 1 hourttl is in seconds, up to a maximum of 3,600 seconds (1 hour). Anything above that is rejected.
Letting the browser upload directly
The worker only issues the URL — the file data never passes through it.
// worker
app.post("/upload-url", async (c) => {
const { name, type } = await c.req.json();
const key = `uploads/${crypto.randomUUID()}/${name}`;
const url = await c.env.storage.presign(key, { method: "PUT", ttl: 600 });
return Response.json({ key, url });
});// browser
const { key, url } = await fetch("/upload-url", {
method: "POST",
body: JSON.stringify({ name: file.name, type: file.type }),
}).then((r) => r.json());
await fetch(url, { method: "PUT", body: file });It matters that the key is generated in the worker. If you use a client-supplied file name as the key, one user can overwrite another user's files.
Only GET and PUT are supported
You cannot create a signed URL for a DELETE request. Deletions must always go through the worker, after a permission check. This keeps a single leaked URL from becoming permission to delete files.
Storage backend limitations
501 error (unsupported).If you need to test a signed URL flow locally, you can add a fallback path where the worker serves the file itself.
let url: string;
try {
url = await env.storage.presign(key, { ttl: 600 });
} catch {
url = `/files/${encodeURIComponent(key)}`; // a route where the worker returns the file with get
}Behavior when the quota is exceeded
Signed URLs for writing are not issued once storage is full (507). Read URLs are still issued, because you need to be able to delete files to free up space.