CORS errors on direct uploads: config problem or dead end?

How to tell in 30 seconds whether your CORS error is a five-minute bucket rule or a backend that can't do browser uploads at all, and what to do in each case.

Usually no — a CORS error is a missing rule on the bucket, not a verdict on the architecture, and on most backends you fix it in five minutes and never think about it again. The exception is a backend that gives you no way to write that rule, and an Infrai bucket is currently one of them: GET /v1/storage/bucket/get/{bucket} reports a cors_rules array, nothing in the API writes to it, and a cors_rules field passed to POST /v1/storage/bucket/create is accepted and silently dropped.

So the useful question isn’t “is CORS broken?” — it’s “can I write a rule on this bucket at all?” Thirty seconds of curl answers it, and the answer decides between editing a policy and changing your upload path.

Read the console string first

Browsers emit different messages for genuinely different failures, and they point at different fixes:

Console messageWhat actually happenedFix
Response to preflight request doesn't pass access control check: No 'Access-Control-Allow-Origin' headerThe OPTIONS never got a CORS answerAdd a rule allowing your origin
Method PUT is not allowed by Access-Control-Allow-MethodsRule exists, doesn’t cover the verbAdd PUT to allowed methods
Request header field content-type is not allowed by Access-Control-Allow-HeadersRule exists, doesn’t cover the header you sendAllow content-type, and any x-amz-* you set
Preflight response is not successful. Status code: 403The bucket refused the preflight outrightUsually no rule set at all
Upload works, but JS can’t read the ETagMissing Access-Control-Expose-HeadersExpose ETag — this one bites multipart

The last row is the sneaky one. Your upload succeeds, the network tab is green, and response.headers.get("etag") returns null, so a multipart uploader has nothing to send to the assemble step.

The 30-second test

Skip the frontend. Sign a slot, then send the preflight by hand:

export INFRAI_API_KEY="your_infrai_api_key"

UPLOAD_URL=$(curl -sS -X POST \
  "https://api.infrai.cc/v1/storage/object/presign/kb-cors-check-0726/probe.txt" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}" \
  -H "Content-Type: application/json" \
  -d '{"op":"put","expires_seconds":600,"content_type":"text/plain"}' \
  | python3 -c "import json,sys; print(json.load(sys.stdin)['data']['url'])")

curl -sS -i -X OPTIONS "$UPLOAD_URL" \
  -H "Origin: https://app.example.com" \
  -H "Access-Control-Request-Method: PUT" \
  -H "Access-Control-Request-Headers: content-type"

Against an Infrai bucket that comes back 403 Forbidden with no Access-Control-Allow-Origin anywhere in the response, and this body:

<Error>
  <Code>AccessForbidden</Code>
  <Message>CORSResponse: This CORS request is not allowed. This is usually because the evalution of Origin, request method / Access-Control-Request-Method or Access-Control-Requet-Headers are not whitelisted by the resource's CORS spec</Message>
  <Resource>/a4ee0c441fa36c267.kb-cors-check-0726/probe.txt</Resource>
</Error>

Now prove the signature was never the problem, by sending the actual upload from a client that doesn’t implement the same-origin policy:

echo "hello" > probe.txt
curl -sS -T probe.txt -H "Content-Type: text/plain" -D - -o /dev/null "$UPLOAD_URL"

HTTP/1.1 200 OK with an ETag. Same URL, same key, same second — curl doesn’t preflight, so it sails through. That asymmetry is the whole diagnosis: your credentials, your signature and your key layout are all fine, and the browser is refusing on policy grounds before any of them get evaluated.

When it’s just configuration

On Amazon S3, Cloudflare R2, Google Cloud Storage or a self-hosted MinIO, you write a rule like this one and the problem evaporates:

[
  {
    "AllowedOrigins": ["https://app.example.com"],
    "AllowedMethods": ["PUT", "GET", "HEAD"],
    "AllowedHeaders": ["content-type", "x-amz-*"],
    "ExposeHeaders": ["ETag"],
    "MaxAgeSeconds": 3600
  }
]

Note ExposeHeaders. Without it the browser hides ETag from your JavaScript even on a successful upload — that’s the failure in the last table row, and it’s the one people burn an afternoon on.

When the rule has nowhere to live

Try to set the same thing at bucket creation on Infrai and watch what comes back:

curl -sS -X POST "https://api.infrai.cc/v1/storage/bucket/create" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}" \
  -H "Content-Type: application/json" \
  -d '{"name":"kb-cors-check-0726","acl":"private","region":"eu-central-1","cors_rules":[{"allowed_origins":["https://app.example.com"],"allowed_methods":["PUT"],"allowed_headers":["*"],"max_age_seconds":3600}]}'
{
  "ok": true,
  "data": {
    "bucket_id": "bkt_c1e9bc20b38946e8a62ab9",
    "name": "kb-cors-check-0726",
    "vendor": "cos",
    "region": "eu-central-1",
    "acl": "private",
    "cors_rules": [],
    "lifecycle_rules": []
  }
}

"ok": true, and cors_rules is empty. The field was accepted and discarded — there’s no error to catch, which is worse than a rejection because it looks like it worked. There is no set_cors route in the storage surface, so on Infrai today the browser-to-bucket leg cannot be made to work. That’s a hard limitation and no amount of header-fiddling in your frontend changes it.

Two ways to keep shipping

Proxy the bytes through your own API. Same origin, no preflight, and you get validation and virus scanning for free. For files under a few megabytes the cost is a request slot you were probably going to spend anyway:

export async function uploadThroughApi(file) {
  if (file.size > 8 * 1024 * 1024) throw new Error("use the split-stack path for files this size");
  const body = new FormData();
  body.append("file", file);

  const res = await fetch("/api/uploads", { method: "POST", body });
  if (!res.ok) throw new Error(`upload failed: ${res.status}`);
  return res.json();
}

Your handler is the only thing that speaks to storage, and PUT /v1/storage/object/put/{bucket}/{key} takes the bytes base64-encoded in a JSON body:

const KEY = process.env.INFRAI_API_KEY;
if (!KEY) throw new Error("INFRAI_API_KEY is not set");

const API = "https://api.infrai.cc";
const BUCKET = "kb-cors-check-0726";

export async function store(objectKey, buffer, contentType) {
  const payload = {
    data_base64: buffer.toString("base64"),
    content_type: contentType,
  };

  const res = await fetch(`${API}/v1/storage/object/put/${BUCKET}/${objectKey}`, {
    method: "PUT",
    headers: { Authorization: `Bearer ${KEY}`, "Content-Type": "application/json" },
    body: JSON.stringify(payload),
  });
  const out = await res.json();
  if (!res.ok || out.ok === false) throw new Error(out?.error?.code ?? `HTTP ${res.status}`);
  return { key: out.data.key, size: out.data.size_bytes, etag: out.data.etag };
}

Or split the stack. Keep the browser-facing bucket on R2 or S3, where you own the CORS policy, and leave everything else where it is. That’s the pragmatic answer for a video product or a big-file uploader: one bucket somewhere with a configurable policy, and the queue, cron, email and error tracking that surround the upload still on one Infrai key.

Confirm either path landed with a free metadata read:

curl -sS "https://api.infrai.cc/v1/storage/object/head/kb-cors-check-0726/probe.txt" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}"

Which path, by workload

Your situationDirect-to-bucket still viable?Recommendation
Files under ~8 MB, web onlyNot neededProxy through your API
Large media, web upload requiredOnly off-InfraiR2 or S3 bucket for uploads
Native mobile or desktop clientYesPresigned PUT — no preflight exists
CLI, CI, server-to-serverYesPresigned PUT, or multipart above 5 GB

Mobile is worth calling out because it’s the case people get wrong in both directions. iOS, Android and React Native don’t implement the same-origin policy, so a signed URL that a browser rejects works perfectly from the app — and if your web upload is the smaller half of your traffic, proxying it is a much smaller change than moving buckets.

What any of this costs

Signing, head, list and bucket calls are free and rate-limited, so the diagnosis above costs nothing. On the data path, verified 26 July 2026, a write through PUT /v1/storage/object/put/{bucket}/{key} is $0.0001 per call and a read is $0.0002, with bytes and egress metered separately — proxying doesn’t change either figure, it just moves where the bandwidth is spent. Pull today’s numbers:

curl -sS "https://api.infrai.cc/v1/discovery" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}" \
  | python3 -c "import json,sys; [print(c['id'], c['billing'].get('price_usd','free')) for c in json.load(sys.stdin)['capabilities'] if c['id'].startswith('storage.object')]"

Those rates trend down over time and campaigns run, so the live figure may be lower than the one printed here.

One last honest note: even if a CORS setter appears, browser-direct uploads still hand an untrusted client a write slot. Sign narrowly — one key, a short expiry, content_type and max_bytes pinned — whichever backend ends up serving the preflight.

References

Browse more storage developer guides