Presigned PUT fails from the browser: reading the CORS error properly
A failure-signature table for browser uploads of generated images, why a valid signature still 403s, and the server-side route that sidesteps cross-origin entirely.
When a canvas-generated PNG won’t upload to a presigned URL, the browser usually reports it as a CORS problem — and about half the time it isn’t one. Three different faults produce near-identical console output: a bucket with no cross-origin rules, a signature that doesn’t cover a header you’re sending, and an expiry that lapsed while the user was picking a filter. Infrai’s presign route (POST /v1/storage/object/presign/{bucket}/{key}) makes the third easy to rule out because the response carries expires_at, and the other two have distinct signatures once you know where to look.
Start by classifying the failure. Guessing costs an afternoon; the request timeline in devtools costs thirty seconds.
Read the failure signature
| What you see | What actually happened | What fixes it |
|---|---|---|
OPTIONS request, then “No ‘Access-Control-Allow-Origin’ header is present” | The bucket has no CORS rule matching your origin. The signature was never checked. | Add a CORS rule on the bucket — on the provider that lets you |
OPTIONS returns 200, PUT returns 403 SignatureDoesNotMatch | You sent a header the signature doesn’t cover, usually Content-Type added by fetch | Sign the same headers you send, or send none beyond host |
| 403 with “Request has expired” | The presigned window closed, or the client clock is skewed | Longer expires_seconds, and mint the URL at upload time, not at page load |
PUT succeeds, object is 0 bytes | A Blob was stringified instead of passed as the body | Pass the Blob/File directly as body |
| 200 in devtools, nothing in the bucket | You uploaded to the wrong key — often an unencoded / in a filename | Derive the key server-side |
The first row is the one people mislabel. A preflight rejection happens before any signature validation, so no amount of re-signing will change it. If you never see an OPTIONS entry at all, you don’t have a CORS problem — you have a request that failed for another reason.
Reproduce it without writing any frontend
Mint a URL, then send the preflight by hand. No React, no build step:
export INFRAI_API_KEY="your_infrai_api_key"
SIGNED_URL=$(curl -sS -X POST \
"https://api.infrai.cc/v1/storage/object/presign/kb-images-0726/generated/tenant_42/poster-2026-07-26.png" \
-H "Authorization: Bearer ${INFRAI_API_KEY}" \
-H "Content-Type: application/json" \
-d '{"op":"put","expires_seconds":600}' \
| python3 -c "import json,sys; print(json.load(sys.stdin)['data']['url'])")
curl -sS -i -X OPTIONS "${SIGNED_URL}" \
-H "Origin: https://app.example.com" \
-H "Access-Control-Request-Method: PUT" \
-H "Access-Control-Request-Headers: content-type" | head -20
If that comes back without Access-Control-Allow-Origin, the browser will refuse the upload no matter what your JavaScript does. The same URL will accept a plain curl -X PUT quite happily, because curl never preflights — which is exactly why “it works in Postman” is such a common and misleading bug report.
Here’s the boundary on this API, stated plainly: GET /v1/storage/bucket/get/{bucket} reports a bucket’s cors_rules, but there’s no route to set them. On a bucket created through Infrai that array stays empty, so a cross-origin PUT from a web page can’t be made to work today. For a browser-direct upload widget you’d be better off on Cloudflare R2 or Amazon S3, where the CORS rule set is yours to edit and both vendors document it properly.
Generated images are the case where you don’t need direct upload
A canvas export, a chart render, a QR code, an AI-generated thumbnail — these have two properties that a user-selected file doesn’t. They’re small, usually tens to a few hundred KB. And your code produced the bytes, so nothing about them is untrusted. That makes routing them through your own server cheap and safe, and it removes the cross-origin question entirely.
Browser side, the whole job is a Blob and a POST to your own origin:
// Same-origin POST — no preflight, no signature, no CORS.
export async function uploadGeneratedImage(canvas, tenantId) {
const blob = await new Promise((resolve) => canvas.toBlob(resolve, "image/png"));
if (!blob) throw new Error("canvas.toBlob returned null");
if (blob.size > 1_000_000) throw new Error(`generated image too large: ${blob.size} bytes`);
const form = new FormData();
form.append("image", blob, "poster.png");
form.append("tenant", tenantId);
const res = await fetch("/api/images", { method: "POST", body: form });
if (!res.ok) throw new Error(`upload failed: ${res.status} ${await res.text()}`);
return (await res.json()).key;
}
Server side, one call stores it. Note that the key is built from the session, never from the request body:
import express from "express";
import multer from "multer";
import { randomUUID } from "node:crypto";
import { requireSession } from "./auth.mjs";
const app = express();
const upload = multer({ limits: { fileSize: 1_000_000 } });
const API = "https://api.infrai.cc";
const BUCKET = "kb-images-0726";
app.post("/api/images", requireSession, upload.single("image"), async (req, res) => {
if (!req.file || req.file.mimetype !== "image/png") {
return res.status(415).json({ error: "png only" });
}
const key = `generated/${req.user.tenantId}/${randomUUID()}.png`;
const payload = {
data_base64: req.file.buffer.toString("base64"),
content_type: "image/png",
cache_control: "public, max-age=31536000, immutable",
};
const stored = await fetch(`${API}/v1/storage/object/put/${BUCKET}/${key}`, {
method: "PUT",
headers: {
Authorization: `Bearer ${process.env.INFRAI_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify(payload),
});
if (!stored.ok) {
console.error("storage put failed", stored.status, await stored.text());
return res.status(502).json({ error: "could not store image" });
}
const { data } = await stored.json();
res.json({ key: data.key, etag: data.etag, size_bytes: data.size_bytes });
});
app.listen(3000);
The trade-off is honest and small: base64 in a JSON body isn’t meant for anything much past 1 MB, and each store is one billable write instead of a free presign. For a 200 KB generated PNG that’s a fine price for deleting a whole class of bug.
What a presigned PUT is actually worth, security-wise
The signature proves the URL was issued. It doesn’t prove who’s using it, and it doesn’t constrain what they upload unless you make it. Three habits matter more than the CORS configuration:
- Derive the key server-side from the authenticated session and a random component. A key taken from client input is a path-traversal invitation into another tenant’s prefix.
- Keep the window short. Sixty to six hundred seconds covers a real upload; an hour just widens the replay window if the URL leaks into a log or an analytics payload.
- Verify after the fact rather than trusting the client’s success callback.
GET /v1/storage/object/head/{bucket}/{key}is free.
curl -sS \
"https://api.infrai.cc/v1/storage/object/head/kb-images-0726/generated/tenant_42/poster-2026-07-26.png" \
-H "Authorization: Bearer ${INFRAI_API_KEY}"
{
"ok": true,
"data": {
"found": true,
"status": "found",
"key": "generated/tenant_42/poster-2026-07-26.png",
"size_bytes": 14,
"etag": "1a3f9c2b4d5e6f708192a3b4c5d6e7f8",
"content_type": "image/png",
"metadata": null,
"last_modified": "2026-07-26T00:19:12Z"
}
}
A found of false after a client reported success means the upload went somewhere you didn’t intend — and it never transfers the body, so the check is free. If you’d rather not poll, POST /v1/storage/bucket/set_notification/{bucket} subscribes object.created to a callback URL that receives a JSON POST with an X-Infrai-Event header — and since that endpoint is yours, verify the payload against your own record of what you expected before acting on it.
Cost, and how to check today’s number
Presigning, head, list and notification setup are free and rate-limited. Writes are billable per call, reads about twice that, and a new account carries $2 of free credit. Verified 26 July 2026: $0.0001 per storage.object.put call.
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')) for c in json.load(sys.stdin)['capabilities'] if c['id'].startswith('storage.object')]"
Rates trend downward and campaigns run, so what you read may be lower than what’s printed here. Buckets take US and EU region codes — us-east-1, eu-central-1, eu-west-1 — at creation, though the presign response’s hostname is the ground truth about where bytes physically land, so check it before promising residency.
What keeps this on one platform isn’t the storage rate. It’s that the generated image, the queue job that produced it, the error trace when the render failed and the per-tenant cost of all three sit behind one key. If you need browser-direct uploads with editable CORS, take R2 — and come back for the rest.