Back to Blog
11 min read
Web

CORS Policy for Browser Uploads: S3, R2, Presigned URLs, and ETag

Create a CORS policy for browser uploads to object storage. Learn how origins, PUT/POST methods, Content-Type, x-amz-* headers, preflight OPTIONS, credentials, and ETag exposure work for S3 and Cloudflare R2.

By Spoold Editorial TeamReviewed for tool accuracy
Editorial Policy

Browser uploads need CORS even when authentication is already solved

Direct browser uploads to S3, Cloudflare R2, or another S3-compatible object store usually use a presigned URL. That URL authorizes the upload, but the bucket CORS policy decides whether the browser is allowed to make the cross-origin request. Spoold's S3/R2 CORS Generator & Debugger helps build and test that policy.

What belongs in a browser upload CORS policy?

A browser upload policy needs to match the actual request that JavaScript sends. For object storage uploads, that usually means a frontend origin, a write method, upload headers, exposed response headers, and a preflight cache duration.

Policy partBrowser upload meaningExample
AllowedOriginsWhere your frontend runshttps://app.example.com
AllowedMethodsUpload method used by the signed URLPUT, POST, HEAD
AllowedHeadersHeaders fetch/XMLHttpRequest sendsContent-Type, x-amz-*
ExposeHeadersResponse headers JS needs after uploadETag
MaxAgeSecondsHow long preflight can be cached3600

PUT vs POST browser uploads

A presigned PUT upload sends the file body directly to the object URL. A presigned POST upload usually submits form data with a policy, signature, key, and file field. Your CORS policy should include the method your backend generated.

  • Use PUT when frontend code calls fetch(url, { method: 'PUT' }).
  • Use POST when frontend code submits a form or FormData policy.
  • Include HEAD when the app checks object existence or metadata after upload.

Choosing a browser upload architecture

Most modern browser upload flows keep large files away from the application server. The backend creates a short-lived presigned URL or form policy, the browser uploads directly to object storage, and the app stores the resulting key, size, content type, or checksum. CORS is the bridge that lets the browser talk to the storage domain while the app itself remains on another origin.

If uploads are private, generate the signed URL only after checking the current user. If uploads are public assets, still restrict object keys and accepted content types on the backend. CORS should not be treated as the security boundary for who may upload; it is the browser permission layer that must match the signed request.

Upload flowBest fitCORS focus
Presigned PUTSingle object uploads from fetchPUT, Content-Type, ETag
Presigned POSTHTML form or multipart policy uploadPOST, form headers, ETag
Backend proxySmall files or strict server inspectionUsually no storage CORS needed
Public read CDNImages, downloads, static assetsGET, HEAD, exposed size headers

Upload headers that trigger preflight

Browsers send an OPTIONS preflight when the request is not a simple CORS request. Uploads frequently send headers such as Content-Type, checksum headers, or S3 metadata headers. The bucket policy must allow those headers.

[
  {
    "AllowedOrigins": ["https://app.example.com"],
    "AllowedMethods": ["PUT", "POST", "HEAD"],
    "AllowedHeaders": [
      "Content-Type",
      "x-amz-*"
    ],
    "ExposeHeaders": ["ETag"],
    "MaxAgeSeconds": 3600
  }
]

Should browser uploads use wildcard origins?

A wildcard origin is tempting for quick testing, but it is usually too broad for app uploads. Use exact origins for production uploads, especially when the request includes credentials, tenant-specific URLs, or sensitive object keys. Public read assets are a better fit for broader origins.

Credentialed requests and signed URLs

Presigned URL uploads usually do not need browser cookies for the storage request itself. The authorization is in the signed URL or form fields. That means your storage CORS policy often does not need to support credentialed browser requests. Keep cookies on your application API, generate the short-lived upload target there, and let the browser upload to storage without sending app cookies to the storage host.

If a custom worker or gateway does require credentials, avoid wildcard origins and return a specific allowed origin. Credentialed CORS is less forgiving, and a permissive-looking policy can still fail in the browser if the response combines credentials with *.

Why ETag needs ExposeHeaders

After an upload succeeds, many apps read ETag to store the uploaded object hash or confirm the write. The browser hides most response headers from JavaScript unless the CORS policy lists them in ExposeHeaders. If upload succeeds but response.headers.get("ETag") returns null, expose the header.

How to test a browser upload policy

  1. Generate the presigned URL from your backend as usual.
  2. Open DevTools and run the upload from the browser.
  3. Find the OPTIONS request and copy origin, method, and request headers.
  4. Paste your policy and those details into the CORS debugger.
  5. Apply the suggested fixed config and retest the browser request.

Browser upload CORS checklist

  • Origin includes protocol, host, and port.
  • Methods include PUT or POST, not only GET.
  • AllowedHeaders includes every preflight request header.
  • ExposeHeaders includes ETag if the app reads it.
  • Wildcard origins are avoided for credentialed or app-specific uploads.
  • Localhost origins are included during development.

Browser upload CORS FAQ

Is CORS enough to stop unwanted uploads?

No. CORS only controls browser behavior. Your backend should still decide who can receive a signed URL, which object key they can write, and how long that URL stays valid.

Why does upload progress work but final metadata is missing?

The upload request can complete while response headers remain hidden. Expose the headers your app reads, especially ETag, Content-Length, or provider metadata headers.

Should staging and production share one policy?

They can, but list each origin explicitly. Separate buckets or separate rules are cleaner when staging uploads should never touch production objects.

Generate and debug in one place

Use the S3/R2 CORS Generator & Debugger to create a starting policy, edit the JSON in Monaco, download provider-specific CLI JSON, and diagnose failed browser uploads from the same page.

Try It Now

Put this guide into practice with our free tools. No sign-up required.

Generate Browser Upload CORS
CORS Policy for Browser Uploads: S3, R2, Presigned URLs, and ETag | Blog | Spoold