CORS errors on direct-to-storage uploads: which rule field is wrong?
A CORS error names the exact part of the bucket rule that didn't match. How to read the console string, write the rule, and tell a CORS refusal from a signature one.
No, it doesn’t kill the approach. A CORS error is the browser telling you that a specific field of the bucket’s rule didn’t match a specific part of your request, and an Infrai bucket carries a rule set you write yourself: POST /v1/storage/bucket/set_cors/{bucket} replaces it, GET /v1/storage/bucket/get/{bucket} reads it back. So the useful question is never “is CORS broken” — it’s which of origin, method or header the browser objected to.
The console string tells you. Read it before you touch anything.
Console message to rule field
| Console message | Which field didn’t match | Change |
|---|---|---|
No 'Access-Control-Allow-Origin' header is present | allowed_origins | Add the exact origin — scheme, host and port, no trailing slash |
Method PUT is not allowed by Access-Control-Allow-Methods | allowed_methods | Add the verb you actually send |
Request header field content-type is not allowed | allowed_headers | Allow content-type and any custom header you set |
Upload succeeds, but JS reads null from response.headers.get("etag") | expose_headers | Expose ETag — this one bites multipart uploaders |
Origins are matched literally. https://app.example.com and https://app.example.com:443 are different strings, http://localhost:5173 is not http://127.0.0.1:5173, and a rule written for production won’t cover your dev server. In practice that’s the field that’s wrong about four times out of five.
Writing the rule
The rule set is replace-the-list, same as lifecycle — send everything you want to keep:
export INFRAI_API_KEY="your_infrai_api_key"
curl -sS -X POST "https://api.infrai.cc/v1/storage/bucket/set_cors/kb-uploads-0726" \
-H "Authorization: Bearer ${INFRAI_API_KEY}" \
-H "Content-Type: application/json" \
-d '{"rules":[{"allowed_origins":["https://app.example.com","http://localhost:5173"],"allowed_methods":["PUT","POST","GET","HEAD"],"allowed_headers":["content-type"],"expose_headers":["ETag"],"max_age_seconds":3600}]}'
{
"ok": true,
"data": {
"bucket": "kb-uploads-0726",
"cors_rules": [
{
"allowed_origins": ["https://app.example.com", "http://localhost:5173"],
"allowed_methods": ["PUT", "POST", "GET", "HEAD"],
"allowed_headers": ["content-type"],
"expose_headers": ["ETag"],
"max_age_seconds": 3600
}
]
}
}
allowed_methods takes GET, PUT, POST, DELETE and HEAD. An empty rules array clears the configuration, and a malformed rule comes back as STORAGE_INVALID_CORS_RULES rather than a partial write. Read it back to be sure the deploy actually landed:
curl -sS "https://api.infrai.cc/v1/storage/bucket/get/kb-uploads-0726" \
-H "Authorization: Bearer ${INFRAI_API_KEY}"
The 30-second isolation test
Before you spend an afternoon on rule fields, prove the signature isn’t the problem. curl doesn’t implement the same-origin policy, so it exercises everything except the browser’s policy check:
UPLOAD_URL=$(curl -sS -X POST \
"https://api.infrai.cc/v1/storage/object/presign/kb-uploads-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'])")
echo "hello" > probe.txt
curl -sS -T probe.txt -H "Content-Type: text/plain" -D - -o /dev/null "$UPLOAD_URL"
200 OK with an ETag means your key, your credentials and your signature are all fine and the browser is refusing on policy grounds before any of them get evaluated. If that same command fails, the problem was never CORS.
The other 403, and how to tell it apart
There’s one failure that looks like a CORS refusal from the network tab and isn’t. When you presign with content_type, the signature covers the Content-Type header — X-Amz-SignedHeaders=content-type;host in the URL is the tell — so the upload must send exactly that value:
curl -sS -X PUT --data-binary @probe.txt -H "Content-Type: application/octet-stream" "$UPLOAD_URL" | head -5
<Error>
<Code>SignatureDoesNotMatch</Code>
<Message>The Signature you specified is invalid.</Message>
</Error>
A CORS refusal produces no response your JavaScript can read at all — err.response is undefined and there’s no status to log. SignatureDoesNotMatch is a real HTTP 403 with a body. If you can see that XML, stop editing rules and go and align the header your uploader sets with the content_type you asked to sign.
Two upload shapes, and why one skips the preflight entirely
The presign response tells you which shape you got, and it’s worth branching on rather than assuming:
export async function uploadDirect(file, objectKey) {
const res = await fetch(`/api/uploads/sign?key=${encodeURIComponent(objectKey)}`);
const { data } = await res.json();
if (data.fields) {
// Policy form: a multipart/form-data POST, which is a simple request.
const form = new FormData();
for (const [name, value] of Object.entries(data.fields)) form.append(name, value);
form.append("file", file);
const post = await fetch(data.url, { method: data.method ?? "POST", body: form });
if (!post.ok) throw new Error(`upload failed: HTTP ${post.status}`);
return { key: objectKey, etag: post.headers.get("etag") };
}
// Signed PUT: send exactly the content type that was signed.
const put = await fetch(data.url, {
method: data.method ?? "PUT",
headers: { "Content-Type": file.type },
body: file,
});
if (!put.ok) throw new Error(`upload failed: HTTP ${put.status}`);
return { key: objectKey, etag: put.headers.get("etag") };
}
Ask for max_bytes on the presign call and you get the first shape — method: "POST", a url pointing at the bucket root, and a fields object carrying the policy, the key and the signature. Copy every field into the FormData before you append the file, in that order. Ask without max_bytes and you get a signed PUT URL instead.
curl -sS -X POST \
"https://api.infrai.cc/v1/storage/object/presign/kb-uploads-0726/inbox/u_2317/photo.png" \
-H "Authorization: Bearer ${INFRAI_API_KEY}" \
-H "Content-Type: application/json" \
-d '{"op":"put","expires_seconds":600,"content_type":"image/png","max_bytes":10485760}'
The max_bytes cap is enforced by the policy itself, so an oversized file is rejected by storage rather than by your validation code — worth flagging, because it means a client can’t lie about the size to get a bigger slot. An invalid op is refused up front too: anything other than get or put is a 400 naming the two accepted values.
Sign narrowly, then queue the rest
Browser-direct upload hands an untrusted client a write slot, so make the slot as small as the job needs: one key, a short expiry, content_type pinned, max_bytes set to your real limit. Then confirm the object landed with a free metadata read:
curl -sS "https://api.infrai.cc/v1/storage/object/head/kb-uploads-0726/inbox/u_2317/photo.png" \
-H "Authorization: Bearer ${INFRAI_API_KEY}"
What happens next is where the single-vendor arithmetic changes. Publishing the post-upload job is POST /v1/queue/publish, making the thumbnail is POST /v1/image/resize, and telling the user is POST /v1/email/send — all on the same key that signed the upload, without another account, another SDK or another invoice for each hop of a three-step pipeline.
Where this approach falls short
The catch is that a presigned URL is a bearer token for one key: whoever holds it can write to that key until it expires, and there’s no revoke — short TTLs are the only control. There’s also no resumable-upload client library here, so a mobile browser on a bad connection restarts a 200 MB file from zero unless you build multipart chunking yourself. If browser-side resumability is a product requirement rather than a nice-to-have, Cloudflare R2 behind a library that already implements it is the honest pick, and a self-hosted MinIO is right when the bytes can’t leave your hardware. For files under a few megabytes, proxying through your own API stays the simplest thing that works — same origin, no preflight, and you get validation for free.