Public buckets & presigned URLs

Four ways to let someone read an object. Picking the wrong one is how credentials end up in a JavaScript bundle.

Choosing

SituationUseWhy
Public assets — a website’s images, CSS, downloadsA public bucketCacheable by any CDN, no signing, no expiry to manage. Anonymous reads only; writes still need a credential.
A private file for one signed-in userA presigned GETYour app decides who may read, then issues a URL that expires. Nothing is public and no credential leaves your server.
A user uploading from the browserA presigned PUTBytes go straight from the browser to storage. Your server signs and stays out of the transfer.
A backend serviceA scoped credentialLong-lived, restricted to the buckets and verbs that service needs. Never in a browser.

Presigned URLs

A presigned URL is a normal URL with a signature in the query string. Anyone holding it can perform that one operation on that one key until it expires — no credential needed, which is the point and also the risk.

JavaScript
// Your API endpoint. Authenticate the user, decide the key yourself, and pin
// the content type into the signature so the browser cannot upload something
// else under it.
async function createUploadUrl({ key, contentType }) {
  return getSignedUrl(
    s3,
    new PutObjectCommand({
      Bucket: process.env.POPCLOUD_BUCKET,
      Key: key,
      ContentType: contentType,
    }),
    { expiresIn: 300 }, // short — the browser uses it immediately
  );
}

For a private bucket, the maximum lifetime is 7 days; a URL signed for the future is refused; editing the key in the URL invalidates it; and Range requests work, which is what makes video seeking work off a presigned link.

Expiry is not an access boundary for a public object. Its clean URL needs no signature, so the object remains readable after signed query parameters expire. Use a private bucket whenever the link must stop working.

Keep the expiry short for uploads — the browser uses it immediately — and only as long as the user session needs for downloads.

Browser uploads

Sign a PUT on your server, hand the URL to the browser, and let the browser upload directly. Two things differ from the AWS tutorials you will find:

You’ll see
403 AccessDenied when a browser submits a multipart/form-data POST to the bucket root, using a policy document and signature as form fields.
Why
The edge parses inbound authentication from the Authorization header and the query string. It never looks at form fields, so a POST policy carries no identity it can verify.
Instead
Use a presigned PUT — it is one URL, it supports Content-Type and size limits through the signature, and every modern uploader supports it. For large browser uploads use presigned multipart via `POST /v1/files/multipart` and `POST /v1/files/multipart/{upload_id}/sign`.
You’ll see
A browser upload fails at the preflight — the OPTIONS request is refused and the real request never runs. PutBucketCors returns 405 MethodNotAllowed.
Why
Bucket configuration is owned by the control plane. Each bucket is provisioned with CORS rules for the dashboard origin; your own origins have to be added there, not through the S3 API.
Instead
Add your origin in the dashboard's bucket settings, or with `PUT /v1/buckets/{bucket}/cors`. Read the current rules with `GET /v1/buckets/{bucket}/cors`. Do this *before* testing a browser upload — a missing rule looks exactly like a broken signature from the browser console.

The full pattern, with progress reporting and the CORS call, is on the browser uploads page.

Public buckets and CDNs

A public bucket is the right origin for a CDN: reads are anonymous, so the CDN never holds a credential. Cache on a fixed TTL and version your object keys rather than relying on revalidation — conditional requests do not behave correctly yet:

You’ll see
A GET carrying If-None-Match, If-Match or If-Modified-Since returns 400 InvalidRequest. Expected behaviour is 304 Not Modified for a cache hit and 412 PreconditionFailed for a stale precondition.
Why
The conditional headers are not handled correctly on the read path. This is a defect, not a design decision.
Instead
Do not send conditional headers on GET. If you are putting a CDN in front of a bucket, cache on a fixed TTL rather than on revalidation, and version your object keys so a new key means new content. Conditional *writes* (If-None-Match on PUT) are separately unsupported.
You’ll see
An anonymous ListObjectsV2 request to a public bucket returns its object keys, even though the caller was only given one object's URL.
Why
Public visibility currently bypasses authentication for every bucket GET and HEAD, including bucket-level listing requests as well as object reads.
Instead
Keep the bucket private when object names or the bucket inventory are sensitive. Use short-lived presigned GET URLs for private downloads. Use a public bucket only for intentionally public assets until anonymous listing is restricted to signed callers.