Skip to content
AnyStorage
Free Download

Cloudflare R2

R2 CORS Error: No Access-Control-Allow-Origin

A browser CORS error against Cloudflare R2 is a bucket policy problem, not a code problem. The policy fields that matter, the three ways to apply one, and the r2.dev trap.

Why R2 returns no Access-Control-Allow-Origin header, what each CORS policy field controls, and how to set the policy from the dashboard, Wrangler or the S3 API.

r2 cors error, r2 cors policy, cloudflare r2 access-control-allow-origin

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.

What each failure looks like and which field fixes it
Symptom in the consoleCauseField to change
No Access-Control-Allow-Origin at allNo CORS policy on the bucketAllowedOrigins
Fails only for PUT, GET is fineMethod not allowedAllowedMethods
"Request header field content-type is not allowed"Header not listedAllowedHeaders
Upload succeeds but response.headers.get('etag') is nullHeader not exposedExposeHeaders
Works from https://example.com, fails from https://www.example.comOrigins must match exactlyAllowedOrigins
403 with no CORS headers at allPresigned URL expiredRegenerate 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-bucket

Or 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-bucket

Allow 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