Public buckets & presigned URLs
Choosing
| Situation | Use | Why |
|---|---|---|
| Public assets — a website’s images, CSS, downloads | A public bucket | Cacheable by any CDN, no signing, no expiry to manage. Anonymous reads only; writes still need a credential. |
| A private file for one signed-in user | A presigned GET | Your 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 browser | A presigned PUT | Bytes go straight from the browser to storage. Your server signs and stays out of the transfer. |
| A backend service | A scoped credential | Long-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.
// 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.