Storage CORS Configuration and Upload Troubleshooting
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 inAllowedOrigins; 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 mentioningCORS 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:
| Symptom | Real cause | How to tell |
|---|---|---|
| Console shows CORS wording, no request in the backend log | The bucket CORS rules do not allow the origin | See "Verify" below |
| Requests stay pending / time out / network error | The 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 AccessDenied | Key lacks permission / bucket policy denies / presigned URL expired | Check 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:
| Setting | Suggested value | Notes |
|---|---|---|
| Origin / AllowedOrigins | The origins of the admin UI and H5; use * while testing | Format is scheme://host[:port] — no path, no trailing slash, no wildcard in the port |
| Methods / AllowedMethods | PUT, GET, HEAD, POST | Uploads 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 / ExposeHeaders | ETag | Required 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
| Provider | Location | Notes |
|---|---|---|
| MinIO | No bucket-level UI; controlled by the server-side variable MINIO_API_CORS_ALLOW_ORIGIN | Usually no configuration needed on a self-hosted setup |
| Aliyun OSS | Bucket → Data Security → CORS | * works for origin and both header fields; besides origin and methods, add the exposed header ETag separately |
| Tencent COS | Bucket → Security Management → CORS | Same as above; the console's "Expose Headers" is where ETag goes |
| Cloudflare R2 | Bucket → 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:
[
{
"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.
# 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).