javascript

Browser uploads

Upload straight from the browser without proxying bytes
verified in CI

Install

shell
npm install @aws-sdk/client-s3 @aws-sdk/s3-request-presigner

Configure

Configure the endpoint, region, credential and bucket in this tool’s vocabulary. Path-style addressing is recommended for dotted bucket names and clients without nested wildcard TLS, but virtual-hosted addressing is also supported.

In Browser uploadsSet it to
endpointhttps://s3.canada.popcloud.ca
regioncanada
credentials.accessKeyIdPCAK00EXAMPLEKEYID00server side only — never ships to the browser
credentials.secretAccessKey<your secret access key>server side only — never ships to the browser
forcePathStyletrue
Environment
example values
export AWS_ACCESS_KEY_ID=PCAK00EXAMPLEKEYID00
export AWS_SECRET_ACCESS_KEY=<your secret access key>
export AWS_ENDPOINT_URL=https://s3.canada.popcloud.ca
export AWS_REGION=canada
export POPCLOUD_BUCKET=pc-your-org-media

These are example values. Sign in and every snippet on this site fills in with your own endpoint, key and bucket.

Use it

Upload an object

JavaScript
// Ask your own API for a signed URL, then PUT the file straight to PopCloud.
// The Content-Type here must match the one your server signed, or the
// signature will not verify.
async function uploadFile(file) {
  const response = await fetch("/api/uploads", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ filename: file.name, contentType: file.type }),
  });
  const { url, key } = await response.json();

  const put = await fetch(url, {
    method: "PUT",
    headers: { "Content-Type": file.type },
    body: file,
  });
  if (!put.ok) throw new Error(`upload failed: ${put.status}`);

  return key;
}

Sign an upload

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
  );
}

Report progress

JavaScript
// fetch() cannot report upload progress. XMLHttpRequest still can, and it is
// the only reason to reach for it here.
function uploadWithProgress(url, file, onProgress) {
  return new Promise((resolve, reject) => {
    const request = new XMLHttpRequest();
    request.open("PUT", url);
    request.setRequestHeader("Content-Type", file.type);
    request.upload.addEventListener("progress", (event) => {
      if (event.lengthComputable) onProgress(event.loaded / event.total);
    });
    request.addEventListener("load", () =>
      request.status < 300 ? resolve() : reject(new Error(`upload failed: ${request.status}`)),
    );
    request.addEventListener("error", () => reject(new Error("upload failed")));
    request.send(file);
  });
}

Sign a download

JavaScript
// Reading back a private object works the same way: sign a GET and hand the
// URL to an <img>, <video> or download link.
async function createDownloadUrl({ key }) {
  return getSignedUrl(
    s3,
    new GetObjectCommand({ Bucket: process.env.POPCLOUD_BUCKET, Key: key }),
    { expiresIn: 3600 },
  );
}

Get an access token

shell
# The control plane authenticates people, not machines: there is no API key.
# Get an access token, and use it for the rest of the session.
TOKEN=$(curl -fsS -X POST "$API/v1/auth/login" \
  -H 'Content-Type: application/json' \
  -d '{"email":"you@example.com","password":"…"}' \
  | python3 -c 'import json,sys; print(json.load(sys.stdin)["access_token"])')

Set the bucket's CORS rules

shell
# Replaces the bucket's rules wholesale — send every origin you need, not just
# the new one. Read the current set first with GET if you are unsure.
#
# An origin is a scheme and host with no trailing slash, or "*". The dashboard's
# own rule is re-added for you if you leave it out, so the file browser keeps
# working from the screen you are editing.
curl -fsS -X PUT "$API/v1/buckets/$BUCKET/cors" \
  -H "Authorization: Bearer $TOKEN" \
  -H 'Content-Type: application/json' \
  -d '{
        "rules": [
          {
            "name": "myApp",
            "origins": ["https://app.example.com", "http://localhost:3000"],
            "methods": ["GET", "PUT", "HEAD"],
            "headers": ["*"],
            "expose_headers": ["etag"],
            "max_age_seconds": 3600
          }
        ]
      }'

Read the current rules

shell
curl -fsS "$API/v1/buckets/$BUCKET/cors" -H "Authorization: Bearer $TOKEN"

What to watch for

These apply to Browser uploads specifically. The full list covers the platform.

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

How this page is kept true

Every snippet above was extracted from examples/browser/presigned-upload, a program that uploads, downloads, compares bytes, lists, presigns and cleans up after itself. It runs in CI against a live sandbox organisation via make docs-verify-node. If it stops passing, this page is wrong and we treat that as a bug in the product.

A few snippets on this page are marked not run in CI — they need a browser, a second provider, or a configuration change the sandbox cannot make. Those are reviewed by hand.

Not yet run against the live sandbox — the runner is wired, the first verified run stamps this line.