Back to Blog
8 min read
Web

R2 Presigned URL CORS: Fix Browser Upload and Download Errors

Cloudflare R2 presigned URLs still need bucket CORS when used from a browser. Learn how to allow PUT, POST, GET, Content-Type, x-amz-* headers, ETag exposure, localhost origins, and preflight requests.

A valid R2 presigned URL can still fail in the browser

Presigned URLs handle permission, but CORS handles browser access. If your React, Next.js, Vue, or plain JavaScript app uploads directly to Cloudflare R2, the bucket still needs a CORS policy that matches the request origin, method, and headers. Use Spoold's S3/R2 CORS Generator & Debugger to test the exact request shape.

Why presigned URLs need CORS

A presigned URL proves that the request is authorized. It does not bypass the browser's same-origin rules. When JavaScript sends a PUT, POST, or GET to an R2 custom domain or public bucket URL, the browser checks whether R2 allows that frontend origin. If the CORS policy is missing or incomplete, the browser blocks the response.

Common R2 presigned URL CORS failures

Browser symptomLikely causeFix
No Access-Control-Allow-Origin headerOrigin not listedAdd the exact app origin
Preflight request does not passPUT or POST missingAdd the upload method
Content-Type is not allowedHeader missingAdd Content-Type to AllowedHeaders
x-amz-meta-* blockedMetadata header missingAdd exact metadata header or x-amz-*
ETag is undefined in JSHeader not exposedAdd ETag to ExposeHeaders

Recommended CORS policy for R2 presigned PUT uploads

Start with a narrow origin list. Include localhost only for development. Add only the methods and headers your upload code sends.

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

What to inspect in DevTools

  1. Open the Network tab and find the failed OPTIONS request.
  2. Copy the Origin request header exactly.
  3. Copy Access-Control-Request-Method. It should match an allowed method.
  4. Copy Access-Control-Request-Headers. Every listed header must be allowed.
  5. Paste those values into the R2 CORS debugger and compare the generated fixed config.

Headers that often surprise upload code

Upload libraries and fetch wrappers can add headers you did not type manually. Watch for Content-Type, Content-MD5, x-amz-checksum-*, x-amz-acl, and custom metadata headers. If the browser sends a header during preflight, the R2 CORS policy needs to allow it.

Why curl succeeds while the browser fails

curl does not enforce browser CORS. It can upload to a valid presigned URL even when the bucket policy would block JavaScript. To simulate browser behavior with curl, send an Origin header and, for preflight checks, an OPTIONS request with Access-Control-Request-Method.

curl -i -X OPTIONS 'https://files.example.com/upload/object.jpg' \
  -H 'Origin: https://app.example.com' \
  -H 'Access-Control-Request-Method: PUT' \
  -H 'Access-Control-Request-Headers: Content-Type,x-amz-acl'

Use the debugger before changing backend code

If the presigned URL works outside the browser, start with CORS. Paste your current policy into the S3/R2 CORS debugger, enter the exact browser request details, and apply the smallest fixed policy that passes.

Try It Now

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

Debug R2 CORS
R2 Presigned URL CORS: Fix Browser Upload and Download Errors | Spoold Blog | Spoold