Skip to content
runlot

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 hour

ttl 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.

Binding the upload size

Pass size and that exact byte count is built into the signature. An upload of any other length is refused by the storage backend with a 403, so the limit holds even though the bytes never reach your worker.

const url = await env.storage.presign(key, { method: "PUT", size: file.size, ttl: 600 });

Without size there is no ceiling on what a single URL can upload. With it, the quota is also checked against that exact size when the URL is issued, rather than only against the space already used.

The browser must send exactly that many bytes. Sending a different file through the same URL fails, so request a new URL whenever the file changes.

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

Signed URLs are only available on the object storage backend. On a disk backend, such as your local development environment, they return a 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. What "full" means differs by plan — see Quota.

Public prefixes

A signed URL is the right tool for a file that one person should be able to read for a while. For files that everyone should be able to read forever — a site's images, uploaded avatars, a download — a signature on every address is the wrong tool. Mark a prefix public instead, and every key under it is readable by plain HTTPS GET, with no signature and no expiry.

runlot storage public add assets
runlot storage public list
runlot storage public rm assets

The same three actions are on the File storage tab in the dashboard, in the "Public prefixes" card. Publishing and unpublishing require the admin role; reading the list requires viewer.

A prefix is stored normalized: assets becomes assets/, and matching is a plain string prefix test on the key. The trailing slash is what keeps assets/ from also opening assets-private/. A lone / publishes every key in the project; an empty prefix is refused so that a blank argument can never mean "everything". A project can publish up to 20 prefixes.

The address

https://objects.runlot.io/<org>/<project>/<key>

A key assets/logo.png in the project shop of the organization acme is at https://objects.runlot.io/acme/shop/assets/logo.png. Only GET and HEAD are served this way — nothing can be written without a signature.

From worker code

env.storage.publicUrl(key) answers that address, and it answers null when the key is not under a public prefix.

const url = env.storage.publicUrl("assets/logo.png"); // "https://objects.runlot.io/acme/shop/assets/logo.png"
const none = env.storage.publicUrl("private/report.pdf"); // null

It is synchronous and makes no request: the answer is string concatenation from the prefix list the worker was started with. null is the ordinary answer for a key nobody published, not an error — the same shape as get returning null for a missing key. A key that would be refused by put (.., an empty segment) throws here too, so a bad key cannot become a tidy-looking URL in an <img src>.

Changing the public prefixes restarts the worker so it sees the new list. A worker started before the change can answer null for a key that is in fact public; it can never answer an address for a key that is not — serving is decided by the object front, not by this value.

What is sent with a public file

HeaderValue
Cache-Controlpublic, max-age=3600
ETag, Content-Type, Content-LengthThe object's
Last-ModifiedThe object's, when known

The cache lifetime is one hour, flat, and immutable is never added: an overwritten key shows its new content within the hour. If you want longer caching, put a content hash in the key and upload a new key when the content changes.

What is never served unsigned

A key outside every public prefix is never served without a signature, and the answer is 404, not 403 — a private key and a key that does not exist look the same from outside, so the address of a private file reveals nothing. The bucket itself stays private throughout: a public read is performed by our front with our own credentials, only after it has checked the list, so a change to the storage backend's own policy cannot widen what is public.

Unpublishing takes effect within 60 seconds: the front caches the list for that long, so readers who already have the address may keep reading for up to a minute after runlot storage public rm returns.

Signed URLs are unaffected by any of this. A signed request is served before the public list is consulted, so publishing a prefix cannot change what a signed URL does.

On this page