Skip to content

The R2 upload that fails in the browser and works in curl: bucket CORS

A presigned PUT from a page is a cross-origin request. Without CORS rules on the bucket it fails with an error that never says CORS, and the signature gets blamed.

Cloudflare R25 min readships at docs/solutions/r2/cors-on-a-bucket.md

Tags: r2 · cors · uploads · presigned · browser · preflight · debugging

Your signing route returns a URL. You PUT the file from the browser and get:

Access to XMLHttpRequest at 'https://abc123.r2.cloudflarestorage.com/...'
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.

or, more often, something less helpful: a network error with status 0, an xhr.onerror with no detail, or a 403 whose body you cannot read. You paste the same URL into curl and it works perfectly. So you go and debug the signature, which is fine, for the next two hours.

The signature was never wrong. The bucket has no CORS rules.

Why the browser is different

curl does not enforce the same-origin policy. A browser does. Your page is on localhost:3000, the PUT goes to abc123.r2.cloudflarestorage.com, and that is cross-origin.

Because the request carries a content-type header the browser considers non-simple, it first sends a preflight: an OPTIONS request asking R2 whether this origin is allowed to send a PUT with that header. A bucket with no CORS configuration answers that OPTIONS with nothing useful, the browser refuses to send the real request, and the failure surfaces in JavaScript as an opaque error with no status.

The credentials were fine. The signature was fine. The request never left.

The fix: rules on the bucket, in the repo

infra/r2/cors.json is the source of truth:

{
  "CORSRules": [
    {
      "AllowedOrigins": ["http://localhost:3000", "https://my-app.vercel.app"],
      "AllowedMethods": ["GET", "PUT", "HEAD"],
      "AllowedHeaders": ["content-type", "content-length"],
      "ExposeHeaders": ["etag"],
      "MaxAgeSeconds": 3600
    }
  ]
}
bun run r2:cors

Keeping this in a file rather than in the dashboard is not ceremony. CORS rules clicked into a console exist in one account, are invisible in code review, and are the reason "uploads work in production but not in preview" takes an afternoon: nobody can see what the two environments actually differ by. Here the diff says it.

Field by field, and the mistakes each one absorbs:

  • AllowedOrigins: an exact scheme + host + port match. localhost:3000 does not cover 127.0.0.1:3000, and https://example.com does not cover https://www.example.com. Every origin that uploads must be listed: production, each preview URL, and localhost.
  • AllowedMethods: what the browser performs, not what the signature allows. PUT for uploads, GET/HEAD if the browser also fetches objects cross-origin. Add POST only if you use form-based uploads.
  • AllowedHeaders: every header the browser sends. content-type is the one that triggers the preflight in the first place. Add a header to the PUT and add it here in the same change, or the preflight starts failing.
  • ExposeHeaders: headers JavaScript is permitted to read from the response. Without etag here, response.headers.get("etag") returns null, which silently breaks multipart uploads at the complete step.
  • MaxAgeSeconds: how long the browser may cache the preflight result. An hour is sensible; while you are actively changing rules, a browser holding an old preflight is why "I fixed it and it still fails".

Never use a wildcard on a bucket that accepts writes

"AllowedOrigins": ["*"]

It makes the error go away and it is a real hole. A presigned URL is a bearer credential: anyone holding it can write that key until it expires. With *, any page a signed-in user visits can use a URL it managed to obtain: from a leaked log, a shared screenshot, an over-eager error reporter. Restricting origins means the browser refuses to make that request at all.

For read-only public assets a wildcard is defensible, but if the objects are genuinely public they should be served through a custom domain, where CORS is configured on the domain and Cloudflare's cache does the work.

Preview deployments

Every preview URL is a distinct origin, and on most hosts every deployment has a new one. Three workable answers:

  1. Do previews against a separate bucket with a permissive origin list, and keep the production bucket's list exact. Best isolation, one more bucket.
  2. List a stable alias. Configure a fixed preview domain (preview.example.com) and upload only from there.
  3. Accept the pattern deliberately: some hosts' preview URLs share a suffix you can match. Write down that you chose it; it is a wider door than it looks.

What does not work: adding origins by hand after each deploy.

Debugging checklist

When an upload fails in the browser, in this order:

  1. Open the Network tab and look for the OPTIONS request. If it is there and failed, it is CORS. If there is no OPTIONS at all and the PUT failed, it is not CORS: go and look at the signature.
  2. Compare the Origin request header against AllowedOrigins character by character. Scheme, host, port.
  3. Re-run bun run r2:cors and confirm which origins it printed. It echoes them, so a stale file is visible immediately.
  4. Hard-reload, or wait out MaxAgeSeconds. Preflight results are cached.
  5. Check the bucket name. A new bucket has no CORS rules at all, so a rename silently reverts you to the original problem.
  6. Only now suspect the signature. A 403 that does reach the server, with SignatureDoesNotMatch in the body, is usually a content-type mismatch between what was signed and what the browser sent.

The one-line summary

If it works in curl and fails in the browser, stop reading your signing code and go and look for the OPTIONS request.