Skip to content

Storage CORS Configuration and Upload Troubleshooting ​

Updated: 10/15/26, 5:44:21 PM

File uploads use presigned direct upload: the browser asks the backend for a presigned URL and then sends the file straight to the object storage, never through the backend. That step is a "page → object storage" cross-origin request, and unless the bucket allows it, the browser blocks the request before it is ever sent.

What makes this painful is that "Test Connection" passes. It is a probe from the backend server, never going through a browser, so it cannot detect cross-origin problems — it catches wrong keys, missing permissions and nonexistent buckets, but never CORS.

Real incident: in 2026-10 a deployment on Cloudflare R2 wrote * into both the "allow headers" and "expose headers" fields of the bucket CORS policy. The console only answered "this policy is invalid", and a lot of time went into swapping browsers and networks. The root cause: R2 accepts * only in AllowedOrigins; both header fields must list names explicitly.

Symptoms ​

  • The browser console reports No 'Access-Control-Allow-Origin' header is present on the requested resource (or an error mentioning CORS policy);
  • The upload spins and then fails, but the backend log shows no such request — the browser blocked it at the preflight stage;
  • "Test Connection" passes and the key permissions are fine, yet every upload fails;
  • Mini-program / app clients upload fine while the admin web UI does not (only browser environments are subject to the same-origin policy).

First: is it really CORS ​

"Upload failed" has at least three different causes, and mixing them up derails the investigation:

SymptomReal causeHow to tell
Console shows CORS wording, no request in the backend logThe bucket CORS rules do not allow the originSee "Verify" below
Requests stay pending / time out / network errorThe presigned URL is unreachable (endpoint set to an internal address, firewall)PUT the presigned URL with curl (server-side requests are not subject to CORS); if it works, CORS is not the problem
Upload returns 403 AccessDeniedKey lacks permission / bucket policy denies / presigned URL expiredCheck the real status code in the backend log or with curl

One-line rule: curl uploads fine while the browser reports CORS = a cross-origin configuration problem; curl fails too = not CORS — go check the endpoint and permissions.

Fix ​

The CORS rule has four settings: origin (the admin UI and H5 origins; * is fine while testing), methods (must include PUT), allow headers (*; list names explicitly on Cloudflare R2), expose headers (ETag recommended). Details of each setting, console entry points per provider, and a paste-ready R2 policy are below.

The four settings ​

Console locations and field names differ per provider, but the semantics map one-to-one:

SettingSuggested valueNotes
Origin / AllowedOriginsThe origins of the admin UI and H5; use * while testingFormat is scheme://host[:port] — no path, no trailing slash, no wildcard in the port
Methods / AllowedMethodsPUT, GET, HEAD, POSTUploads use PUT, reads and previews use GET/HEAD; allowing only GET fails the upload preflight
Allow Headers / AllowedHeaders* (list names explicitly on Cloudflare R2)Must cover the headers the browser actually sends; see the tip below
Expose Headers / ExposeHeadersETagRequired for ETag/MD5 verification in JavaScript; otherwise the header is unreadable

About * for Origin (allow every origin): presigned URLs carry their signature in query parameters and send no cookies, so the wildcard works. If a future flow sends cookies cross-origin, browsers reject "wildcard + credentials" together and you must list the real origins.

Do not guess which request headers to allow: open the dev tools → Network → the OPTIONS preflight request of the upload → the names in its Access-Control-Request-Headers header are the list.

Where to configure it per provider ​

ProviderLocationNotes
MinIONo bucket-level UI; controlled by the server-side variable MINIO_API_CORS_ALLOW_ORIGINUsually no configuration needed on a self-hosted setup
Aliyun OSSBucket → Data Security → CORS* works for origin and both header fields; besides origin and methods, add the exposed header ETag separately
Tencent COSBucket → Security Management → CORSSame as above; the console's "Expose Headers" is where ETag goes
Cloudflare R2Bucket → Settings → CORS Policy (JSON editor)* only in AllowedOrigins; both header fields must list names explicitly

For other providers (Huawei OBS, AWS S3, Qiniu Kodo, ...) configure the same four settings in the bucket's CORS section; console naming follows each provider's official docs.

R2 policy to paste ​

The R2 CORS policy is edited as JSON; the policy below can be pasted as is:

json
[
  {
    "AllowedOrigins": ["*"],
    "AllowedMethods": ["GET", "PUT", "POST", "DELETE", "HEAD"],
    "AllowedHeaders": [
      "Content-Type",
      "Content-Length",
      "Content-MD5",
      "Content-Disposition",
      "Content-Encoding",
      "Cache-Control",
      "Authorization",
      "x-amz-acl",
      "x-amz-date",
      "x-amz-content-sha256"
    ],
    "ExposeHeaders": ["ETag", "Content-Length", "Content-Type", "Last-Modified"],
    "MaxAgeSeconds": 3600
  }
]

Note that on R2 only AllowedOrigins supports *; the AllowedHeaders / ExposeHeaders fields must list names explicitly. To allow more request headers (for example specific x-amz-meta-* names for custom metadata), append them to AllowedHeaders one by one.

If the console refuses to save, set the policy with the official CLI instead (its errors point at the exact field): npx wrangler r2 bucket cors set <bucket> --file cors.json (file format: {"rules":[{"allowed":{"origins":[...],"methods":[...],"headers":[...]},"exposeHeaders":[...],"maxAgeSeconds":3600}]}).

Verify ​

Verify in two layers: first that the presigned URL works server-side, then that the browser side is allowed.

bash
# 1. PUT the presigned URL from the server (no browser involved, CORS does not apply)
curl -X PUT --upload-file ./test.png "https://<presigned-url>"

# 2. Send an OPTIONS with an Origin header and inspect the CORS response headers
curl -sD - -o /dev/null -X OPTIONS \
  -H "Origin: https://admin.example.com" \
  -H "Access-Control-Request-Method: PUT" \
  "https://<presigned-url>" | grep -i "access-control"
  • Step 1 succeeds but step 2 shows no Access-Control-Allow-Origin: the CORS rule is missing or not in effect — go back to "Fix";
  • Both steps fail: not a CORS issue — check whether the endpoint is reachable from the browser, the key permissions, and whether the presigned URL has expired.

If a rule change still does not take effect, it is usually one of three layers of delay: rule propagation (officially up to tens of seconds), the browser preflight cache (per MaxAgeSeconds — retest in an incognito window), or a CDN / custom domain cache (needs refreshing).