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 symptom | Likely cause | Fix |
|---|---|---|
| No Access-Control-Allow-Origin header | Origin not listed | Add the exact app origin |
| Preflight request does not pass | PUT or POST missing | Add the upload method |
| Content-Type is not allowed | Header missing | Add Content-Type to AllowedHeaders |
| x-amz-meta-* blocked | Metadata header missing | Add exact metadata header or x-amz-* |
| ETag is undefined in JS | Header not exposed | Add 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
- Open the Network tab and find the failed
OPTIONSrequest. - Copy the
Originrequest header exactly. - Copy
Access-Control-Request-Method. It should match an allowed method. - Copy
Access-Control-Request-Headers. Every listed header must be allowed. - 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.
| Layer | What to compare | Common mismatch |
|---|---|---|
| Backend signer | Method, key, content type, metadata, expiry | Signed PUT but frontend uses POST |
| Frontend request | Actual fetch method and headers | Upload library adds x-amz-meta-* |
| R2 bucket CORS | Origin, method, allowed headers, expose headers | Localhost port is missing |
| Browser response | Readable response headers | ETag 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.
Related Tools
Related Articles
Cloudflare R2 CORS Generator: Create Bucket CORS JSON for Browser Apps
Generate Cloudflare R2 CORS JSON for public reads, browser uploads, signed downloads, and presigned URL workflows. Learn AllowedOrigins, AllowedMethods, AllowedHeaders, ExposeHeaders, MaxAgeSeconds, and Wrangler-ready config.
S3 CORS Policy Generator: Create AWS Bucket CORS JSON for Browser Uploads
Generate AWS S3 CORS policies for browser uploads, public reads, signed downloads, and presigned URLs. Learn CORSRules, AllowedOrigins, AllowedMethods, AllowedHeaders, ExposeHeaders, MaxAgeSeconds, and put-bucket-cors.
Fix Cloudflare R2 CORS Error: Preflight, Origin, Headers, and ETag
Troubleshoot Cloudflare R2 CORS errors in browser apps. Fix missing Access-Control-Allow-Origin, failed preflight OPTIONS, blocked Content-Type or x-amz-* headers, localhost origins, and hidden ETag response headers.