The message, and what it is not
text
Access to fetch at 'https://<ACCOUNT_ID>.r2.cloudflarestorage.com/bucket/key'
from origin 'http://localhost:3000' has been blocked by CORS policy:
Response to preflight request doesn't pass access control check:
No 'Access-Control-Allow-Origin' header is present on the requested resource.Nothing is broken in your fetch call, your presigned URL is probably valid, and curl will happily download the same object. The browser is refusing to hand the response to your JavaScript because R2 did not say it was allowed to.
Two facts from Cloudflare's R2 CORS documentation frame the whole problem. First, the policy lives on the bucket, not in your code. Second, "Only a cross-origin request will include CORS response headers", and R2 identifies those by the presence of an Origin header — which is why the same URL pasted into the address bar works and tells you nothing.
| Symptom in the console | Cause | Field to change |
|---|---|---|
No Access-Control-Allow-Origin at all | No CORS policy on the bucket | AllowedOrigins |
Fails only for PUT, GET is fine | Method not allowed | AllowedMethods |
| "Request header field content-type is not allowed" | Header not listed | AllowedHeaders |
Upload succeeds but response.headers.get('etag') is null | Header not exposed | ExposeHeaders |
Works from https://example.com, fails from https://www.example.com | Origins must match exactly | AllowedOrigins |
| 403 with no CORS headers at all | Presigned URL expired | Regenerate the URL |
1. Write the policy, explicitly
R2 accepts the S3-style CORS JSON with five fields: AllowedOrigins, AllowedMethods, AllowedHeaders, ExposeHeaders and MaxAgeSeconds. Cloudflare's own browser-upload example is the shape to copy:
json
[
{
"AllowedOrigins": ["https://example.com"],
"AllowedMethods": ["PUT"],
"AllowedHeaders": ["Content-Type"],
"ExposeHeaders": ["ETag"],
"MaxAgeSeconds": 3600
}
]Note what Cloudflare's example does *not* do: it does not put "*" in AllowedHeaders. It lists the header the client actually sends. The Cloudflare Community threads behind this error are full of policies where "*" was expected to work like it does on AWS and the preflight still failed, and where replacing it with the literal header name fixed the upload in one edit. Be explicit; it costs one line and removes an entire class of doubt.
The wildcard rule is worth knowing from the browser side too. Per the CORS specification as documented on MDN, when a request carries credentials the server "must not specify the * wildcard for the Access-Control-Allow-Origin response-header value, but must instead specify an explicit origin", and the same restriction applies to Access-Control-Allow-Headers, Access-Control-Allow-Methods and Access-Control-Expose-Headers.
2. Apply it: dashboard, Wrangler, or the S3 API
In the dashboard: R2 object storage, your bucket, Settings, then under CORS Policy select Add CORS policy and paste the JSON.
From Wrangler, which is the version you can keep in the repository:
sh
npx wrangler r2 bucket cors set my-bucket --file cors.json
npx wrangler r2 bucket cors list my-bucketOr through the S3-compatible API, since R2 speaks it. Configure the AWS CLI with region auto — Cloudflare's CLI example notes the region is required by the SDK but not used by R2 — and pass the account endpoint:
sh
aws s3api put-bucket-cors \
--endpoint-url https://<ACCOUNT_ID>.r2.cloudflarestorage.com \
--bucket my-bucket \
--cors-configuration file://cors.json
aws s3api get-bucket-cors \
--endpoint-url https://<ACCOUNT_ID>.r2.cloudflarestorage.com \
--bucket my-bucketAllow a few seconds before retesting: R2's documentation warns that a policy change takes time to propagate, and a browser that just cached a failed preflight will not ask again until MaxAgeSeconds expires. Cloudflare notes browsers may cap that cache at around two hours regardless of what you set.
3. Understand what the browser is actually asking
The failing request is usually not your PUT. It is the preflight. As MDN puts it, "for 'preflighted' requests the browser first sends an HTTP request using the OPTIONS method to the resource on the other origin, in order to determine if the actual request is safe to send." That OPTIONS request carries Access-Control-Request-Method and Access-Control-Request-Headers, and your policy has to satisfy both.
You can reproduce it without a browser, which turns guesswork into a two-second check:
sh
curl -i -X OPTIONS \
-H "Origin: https://example.com" \
-H "Access-Control-Request-Method: PUT" \
-H "Access-Control-Request-Headers: content-type" \
"https://<ACCOUNT_ID>.r2.cloudflarestorage.com/my-bucket/test.png"A correct policy answers with Access-Control-Allow-Origin, Access-Control-Allow-Methods and Access-Control-Allow-Headers echoing what you asked for. Anything else is the policy, not your framework.
A simple request needs no preflight at all: MDN lists GET, HEAD and POST with only CORS-safelisted headers — Accept, Accept-Language, Content-Language, Content-Type limited to three MIME types, and Range. Add an x-amz-* header or a JSON content type and you are back in preflight territory.
4. Origins are strings, not patterns
AllowedOrigins entries must match the browser's Origin header exactly: scheme, host and port, with no path. http://localhost:3000 and http://localhost:5173 are two entries. https://example.com does not cover https://www.example.com. A trailing slash makes it a non-match.
5. r2.dev versus a custom domain
If you are testing against the public development URL, read this first: Cloudflare states that "Public access through r2.dev subdomains is rate-limited and should only be used for development purposes", and separately warns against pointing a CNAME at an r2.dev subdomain because that is "an unsupported access path".
Custom domains behave better and are documented to do so: a custom domain connected to a bucket with a CORS policy automatically returns CORS response headers for cross-origin requests. Two consequences follow. A custom domain sits behind Cloudflare's cache, so after changing the policy you may need to purge the cache before the old preflight response stops being served. And a presigned URL signs the host it was generated for, so a URL made for the account endpoint will not work on the custom domain.
6. Presigned uploads that fail with no CORS headers
Expiry looks like a CORS error but is not one. R2's documentation notes that an expired presigned URL returns a 403 ExpiredRequest without CORS headers, so the browser reports the missing header and hides the real cause. Check the X-Amz-Expires value and the clock before touching the policy again. If the object uploads but your code cannot read the ETag, that is ExposeHeaders, and the fix is one array.
When the browser is not actually required
CORS exists to protect browsers, so it only applies to browsers. A desktop client signs its own requests, never sends an Origin header, and no policy can block it — which is why a bucket that is unusable from your web app can be browsed fine from an app in the same minute.
AnyStorage (v0.2.24; macOS 11+, Windows 10+, Ubuntu 20.04+) connects to R2 as an S3-compatible endpoint: the account endpoint URL, an R2 access key pair, path-style addressing and a custom region available as explicit settings. It mounts through a built-in local WebDAV server at http://127.0.0.1:3211, so an R2 bucket can appear as a drive without FUSE, WinFsp or macFUSE. The free tier allows two connections. For moving files, no CORS policy is involved at all; for a web app, you still need the policy above.
Can I just allow everything?
You can set AllowedOrigins: ["*"] and list the methods you use, and for a bucket of public images that is defensible. Do not reach for "*" in AllowedHeaders: Cloudflare's own examples name headers, and the community reports say the wildcard is where uploads stall.
Why did it work yesterday?
The usual answers are a changed origin (a preview deployment gets a new hostname), a purged policy on a re-created bucket, or a preflight cached from before the change. Re-run the curl -X OPTIONS check above to see what R2 is answering right now.
Do I need CORS for server-side uploads?
No. A Worker, a Node process or a CI job is not subject to CORS. Only requests made by browser JavaScript are.
Does the policy affect the R2 bindings in a Worker?
No. Bucket bindings go through Cloudflare's internal API rather than the S3 endpoint, so CORS never enters the path. The policy only matters for requests a browser makes directly to the bucket.
Next steps
- Mount a bucket as a drive: mount Cloudflare R2 as a local drive
- The signature-side sibling of this error: MinIO SignatureDoesNotMatch
- Links that expire on purpose: S3 presigned URL generator