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:3000does not cover127.0.0.1:3000, andhttps://example.comdoes not coverhttps://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.PUTfor uploads,GET/HEADif the browser also fetches objects cross-origin. AddPOSTonly if you use form-based uploads.AllowedHeaders: every header the browser sends.content-typeis 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. Withoutetaghere,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:
- Do previews against a separate bucket with a permissive origin list, and keep the production bucket's list exact. Best isolation, one more bucket.
- List a stable alias. Configure a fixed preview domain
(
preview.example.com) and upload only from there. - 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:
- Open the Network tab and look for the
OPTIONSrequest. If it is there and failed, it is CORS. If there is noOPTIONSat all and thePUTfailed, it is not CORS: go and look at the signature. - Compare the
Originrequest header againstAllowedOriginscharacter by character. Scheme, host, port. - Re-run
bun run r2:corsand confirm which origins it printed. It echoes them, so a stale file is visible immediately. - Hard-reload, or wait out
MaxAgeSeconds. Preflight results are cached. - Check the bucket name. A new bucket has no CORS rules at all, so a rename silently reverts you to the original problem.
- Only now suspect the signature. A 403 that does reach the server, with
SignatureDoesNotMatchin the body, is usually acontent-typemismatch 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.