# 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.

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:

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

```bash
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

```json
"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.

---

Agentic Boilerplate: A Next.js repo your agent already knows. Free during launch, then $99 once.

- Site map for agents: https://agenticboilerplate.com/llms.txt
- Public API: https://agenticboilerplate.com/openapi.json
- Contact: agenticstudio@gmail.com
