Skip to content
Files SDK
Esc
↑↓navigate↵open⌘Jpreview
On this page

Fix Cloudflare R2 CORS and presigned URL 403 errors

Tell a CORS rule problem from a rejected signature when browser uploads to R2 fail, and fix each cause, from origin mismatches to expired presigned URLs.

A browser upload to a presigned R2 URL fails in one of two ways in the Files SDK client: network error during upload when the browser blocked the response, or upload failed (403) when R2’s refusal reached your code. The first usually means the bucket’s CORS rule doesn’t match the request, but not always. R2 sends an expired URL’s 403 without CORS headers, so the browser reports that as a CORS failure too.

Start by taking the browser out of the loop. curl ignores CORS, so replaying the PUT with curl tells you whether R2 accepts the request at all, and a preflight sent with an Origin header tells you whether your rule matches. Every cause below falls on one side of that split.

Before you start

  • The failing upload’s request URL, copied from the browser’s network panel. It’s the PUT to <account-id>.r2.cloudflarestorage.com.
  • The exact origin of the page that sent it: scheme, host, and port.
  • Access to the bucket’s CORS policy, in the dashboard under Settings → CORS Policy or with npx wrangler r2 bucket cors list <bucket>.
  • Written against files-sdk 3.0 and Cloudflare’s R2 documentation as of October 2026.

Treat the URL as a secret while you debug. Cloudflare describes presigned URLs as bearer tokens: anyone holding one can use it until it expires.

What the client sends to R2

upload(file) in files-sdk/client and files-sdk/react presigns through your gateway, sends the bytes, then calls complete. The request that reaches R2 is small:

  • Method: PUT. R2 doesn’t support presigned POST uploads, so it’s always a PUT, and a PUT always triggers a CORS preflight.
  • Headers: Content-Type, set to the type the gateway signed, and nothing else. The signature travels in the query string (X-Amz-Signature, X-Amz-Expires, and so on), so there’s no Authorization header and no x-amz-* header. Headers you configure on createFilesClient({ headers }) go to your gateway, never to R2.
  • Errors: the client sends with XMLHttpRequest. When the browser blocks the request or its response, the request fires error and the client throws network error during upload. When R2 answers with a status the browser lets it read, any status outside 2xx becomes upload failed (<status>).

Check where the PUT went before you go further. If it went to your own /api/files?op=proxy&token=… instead of R2, the gateway fell back to proxying the upload through your server, and the bucket’s CORS rule isn’t involved. On R2, setting maxUploadSize on the gateway causes that fallback.

Replay the upload with curl

Run both commands against the URL from the network panel. Use the same Content-Type the browser sent, and a small test file, because the first command really uploads it to that key.

URL='https://<account-id>.r2.cloudflarestorage.com/uploads/users/42/3f1c….pdf?X-Amz-Algorithm=…'

# 1. Does R2 accept the request? curl sends no Origin, so CORS doesn't apply.
curl -i -X PUT "$URL" \
  -H "Content-Type: application/pdf" \
  --data-binary @test.pdf

# 2. Does the CORS rule match your page?
curl -i -X OPTIONS "$URL" \
  -H "Origin: https://app.example.com" \
  -H "Access-Control-Request-Method: PUT" \
  -H "Access-Control-Request-Headers: content-type"

Keep the single quotes around the URL; its query string is full of &. Then read the two results together:

PUT with curl Preflight Where the problem is
200 No Access-Control-Allow-Origin The CORS rule. Go to Fix the CORS rule.
200 Access-Control-Allow-Origin with your origin The rule matches this request. Compare it with the browser’s own preflight, then see The rule is right but uploads still fail.
403 with an XML body Either R2 rejects the request itself. Go to Read R2’s 403.

Cloudflare’s CORS docs note that requests without an Origin header never get CORS headers back. That’s why the first command can succeed while the browser fails, and why the second command needs -H "Origin: …" to show anything.

Fix the CORS rule

The Next.js guide has the base policy for these uploads: your origins, PUT, and Content-Type. When the preflight comes back without Access-Control-Allow-Origin, one of these parts doesn’t cover the request.

The origin doesn’t match

An origin without a wildcard must match the request’s Origin header exactly. Cloudflare’s rules for AllowedOrigins:

  • An origin is scheme://host[:port], with no path and no trailing slash. https://app.example.com/ is invalid.
  • http://localhost:3000, http://127.0.0.1:3000, and http://localhost:5173 are three different origins. Ports can’t contain wildcards, so list each local port you use.
  • A pattern can hold one *: https://*.example.com matches https://app.example.com, but not https://example.com.

For preview deployments, add one narrow pattern for their hostnames instead of *. A wildcard of * lets scripts on any site use a leaked presigned URL against your bucket. It also fixes none of the causes further down, which show the same browser error and have nothing to do with origins.

The method or a header isn’t allowed

AllowedMethods needs PUT. A download link that redirects to a presigned GET is a top-level navigation, not a CORS request, so GET is only needed if your JavaScript fetches the file itself.

AllowedHeaders must cover every header listed in the browser’s preflight Access-Control-Request-Headers. From the Files SDK client that’s only content-type. If your own code sends the PUT and adds headers, such as x-amz-meta-* metadata or Cache-Control, list each one. To confirm a header problem, repeat the curl preflight without -H "Access-Control-Request-Headers: content-type". If Access-Control-Allow-Origin appears now, the header list is what’s missing.

You don’t need ExposeHeaders for these uploads. The client only reads the status of R2’s response, and the complete step reads the object’s size and ETag on the server.

The rule is right but uploads still fail

  • Wait for it to apply. Cloudflare says CORS changes can take up to 30 seconds to propagate.
  • Check the bucket. The rule has to be on the bucket in the URL’s path. For a bucket in the EU jurisdiction, pass --jurisdiction eu to the Wrangler command.
  • Compare preflights. In the network panel, open the browser’s OPTIONS request and copy its Origin and Access-Control-Request-Headers into your curl preflight. A header your curl command didn’t send is the usual difference.
  • Check the PUT response. If the preflight passes and the PUT itself is blocked, the response to the PUT had no CORS headers. That’s R2 rejecting the request, which the next section covers.

Read R2’s 403

When curl’s PUT fails, the XML body’s <Code> names the cause. The codes below are from R2’s error code list.

ExpiredRequest

“Presigned URL or request signature has expired.” R2 doesn’t include CORS headers on this response, so in the browser it looks exactly like a CORS failure, and the client reports network error during upload.

The URL carries its own clock: X-Amz-Date is when it was signed (UTC), and X-Amz-Expires is how many seconds it lives. The gateway signs for 300 seconds by default and clamps that to authorize’s maxExpiresIn. Look for something that holds on to a URL: a retry that replays an old target, or a custom flow that presigns when the page loads and uploads much later. upload(file) presigns right before it sends, so calling it again gets a fresh URL. If uploads legitimately start late, raise the gateway’s defaultExpiresIn a little, along with the maxExpiresIn that caps it, rather than reusing URLs.

SignatureDoesNotMatch

“Request signature does not match calculated signature.” R2 recomputed the signature from the request it received and got a different answer. Three things cause it:

  • A different Content-Type than the one signed. Cloudflare’s presigned URL docs say an upload with a different type fails this way. That applies when the URL’s X-Amz-SignedHeaders includes content-type, which it does whenever signedUploadUrl() gets a contentType, on either client ("fetch" or "aws-sdk") and with hybrid signing in a Worker. The gateway always passes the browser’s type, and the Files SDK client always sends it back unchanged. Your own code might not: fetch(url, { method: "PUT", body: file }) sends the file’s own type, so pass the headers that signedUploadUrl() returned. If X-Amz-SignedHeaders is only host, no type was signed (no contentType, or client: "aws-sdk" before files-sdk 2.7), and this cause can’t produce the error.
  • A URL changed after signing. Cloudflare lists the key, the operation, and the expiry. In practice that’s a key re-encoded on the way to the browser, a query parameter added or reordered by your code, or an upload URL used with a method other than PUT.
  • The wrong secret. The access key ID and secret come from different tokens, or the secret was rotated on one server and not another.

Cloudflare doesn’t document whether this 403 carries CORS headers. Check the response in the network panel: with Access-Control-Allow-Origin, the client reports upload failed (403); without it, network error during upload.

AccessDenied or 401 Unauthorized

AccessDenied means “insufficient permissions for the requested operation”, and a 401 Unauthorized means “missing or invalid authentication credentials”. For uploads, the first usually means the token is read-only or scoped to another bucket, and the second a revoked or mistyped access key ID. Give the token Object Read & Write on this bucket.

Signing happens on your server without contacting R2, so a bad token doesn’t fail at presign. It fails at the PUT, in the browser, which is why it can look like an upload problem rather than a credentials one.

The wrong endpoint

The adapter signs path-style URLs: https://<account-id>.r2.cloudflarestorage.com/<bucket>/<key>. Check each part of the failing URL:

  • Account ID. It must match the one on your R2 overview page. A typo in R2_ACCOUNT_ID sends the request to an endpoint where your token doesn’t work.
  • Bucket. NoSuchBucket means “the specified bucket does not exist” at that endpoint. Check the bucket you pass to r2().
  • Jurisdiction. A bucket created in the EU jurisdiction is reached through https://<account-id>.eu.r2.cloudflarestorage.com, and Cloudflare’s data location docs require the jurisdiction in the S3 endpoint. Pass that URL as endpoint to r2(); it replaces accountId.

Custom domains

Presigned URLs only work on the S3 API domain; Cloudflare says they can’t be used with custom domains. Files SDK never signs for one. signedUploadUrl() and url(key, { expiresIn }) always target <account-id>.r2.cloudflarestorage.com, and when you set publicBaseUrl, a plain url(key) returns an unsigned ${publicBaseUrl}/${key} link. So if a URL you expected to be presigned starts with your custom domain, it came from a url() call without expiresIn. Rewriting a presigned URL’s host to your domain breaks the signature.

Cloudflare’s troubleshooting page covers CORS errors on custom-domain reads: a cf-mitigated header means a WAF rule blocked the request, and a missing cf-cache-status points at Hotlink Protection. Those apply to public reads through your domain, not to presigned uploads.

Cause at a glance

Cause curl PUT Files SDK client error
Origin, method, or header not in the rule 200 network error during upload
Rule not applied yet, or on another bucket 200 network error during upload
Expired URL 403 ExpiredRequest network error during upload
Signed Content-Type differs 403 SignatureDoesNotMatch upload failed (403) or network error during upload
URL modified, or wrong secret 403 SignatureDoesNotMatch Same as above
Token lacks access, or key revoked 403 AccessDenied or 401 upload failed (…) or network error during upload
Wrong bucket, account, or jurisdiction Error from that endpoint Depends on the response’s CORS headers

Last updated on

Was this page helpful?