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.

By Spoold Editorial TeamReviewed for tool accuracy
Editorial Policy

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.

Presigned URL success path to verify

A working R2 presigned URL browser upload has three separate pieces. First, your backend signs the correct method, object key, content type, expiration, and any required metadata. Second, the frontend sends the same method and headers that were signed. Third, the R2 bucket CORS policy allows the browser origin, method, and request headers. If one piece is different, the error may appear as a CORS failure even when the root cause is a signed URL mismatch.

For example, if the backend signs a PUT URL with Content-Type: image/png, but the frontend uploads without that header or with image/jpeg, R2 may reject the request. If the frontend sends the correct header but CORS does not allow Content-Type, the browser can block the request during preflight before the upload body is sent.

LayerWhat to compareCommon mismatch
Backend signerMethod, key, content type, metadata, expirySigned PUT but frontend uses POST
Frontend requestActual fetch method and headersUpload library adds x-amz-meta-*
R2 bucket CORSOrigin, method, allowed headers, expose headersLocalhost port is missing
Browser responseReadable response headersETag exists but is not exposed

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.

Keep signed URL expiration short and generate URLs from your own backend after checking the user. CORS can make a browser request possible, but it does not decide who should receive an upload URL or which object key they may write. Treat the CORS policy as the browser compatibility layer and the signer as the real access control layer that stays explicit, reviewable, and auditable over time.

R2 presigned URL CORS FAQ

Should I allow all headers with x-amz-*?

It is convenient for S3-compatible upload flows because metadata, ACL, checksum, and signing headers often use that prefix. For a stricter policy, inspect the preflight request and list only the exact headers your app sends.

Do downloads need PUT or POST in CORS?

No. A signed download usually needs GET and often HEAD. Add upload methods only for browser writes.

Why is ETag null after upload succeeds?

The upload can succeed while JavaScript still cannot read the ETag header. Add it to ExposeHeaders, then retest the browser response.

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 | Blog | Spoold