Why a browser can't PUT thumbnails straight into a private bucket
The preflight fails before your signature is ever checked. What that means for a thumbnail pipeline, and why deriving sizes server-side costs less anyway.
A presigned upload from a web page doesn’t fail because the signature is wrong. It fails one step earlier: the browser sends an OPTIONS preflight, the bucket finds no cross-origin rule matching your origin, and answers 403 AccessForbidden before any signature is examined. Infrai reports a bucket’s cors_rules on GET /v1/storage/bucket/get/{bucket} but exposes no route to write them, so on a bucket created here that array stays empty and a cross-origin PUT from a tab can’t be made to work.
The identical URL works from curl, from a native mobile client, from your own server. That asymmetry is what makes the bug report so confusing — and it’s also the clue, because for thumbnails specifically the upload has no business being in the browser in the first place.
The preflight, byte for byte
You can watch this happen without a line of frontend code. Mint a slot against a real key, then hand-send the preflight the browser would have sent:
import os
import sys
import requests
API = "https://api.infrai.cc"
KEY = os.environ.get("INFRAI_API_KEY")
if not KEY:
sys.exit("INFRAI_API_KEY is not set")
slot = requests.post(
f"{API}/v1/storage/object/presign/kb-thumbs-0726/derived/tenant_7/img_9f2a3c/320.webp",
headers={"Authorization": f"Bearer {KEY}", "Content-Type": "application/json"},
json={"op": "put", "expires_seconds": 600, "content_type": "image/webp"},
timeout=20,
)
slot.raise_for_status()
signed = slot.json()["data"]
preflight = requests.options(
signed["url"],
headers={
"Origin": "https://app.example.com",
"Access-Control-Request-Method": "PUT",
"Access-Control-Request-Headers": "content-type",
},
timeout=20,
)
print(preflight.status_code)
print(preflight.headers.get("Access-Control-Allow-Origin", "<absent>"))
print(preflight.text)
Run it and the storage layer says exactly what it thinks of your origin:
<?xml version='1.0' encoding='utf-8' ?>
<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>/kb-thumbs-0726/derived/tenant_7/img_9f2a3c/320.webp</Resource>
</Error>
No Access-Control-Allow-Origin header, so the browser stops there and never sends the body. Your signature was fine. It was simply never consulted.
Five constraints that a perfect CORS rule wouldn’t remove
Suppose you’re on a bucket where the rule set is yours to edit. Presigned uploads still carry limitations that teams tend to discover in week three rather than week one, and a thumbnail pipeline hits all of them at once.
- One URL is one key and one method. A five-size variant set needs five signatures and five round trips, each of which can half-finish on a phone that just walked into a lift.
- The signature doesn’t bound the body unless you bound it. Pass
max_byteswhen you presign, or a client that lies aboutContent-Lengthwrites whatever it likes into your prefix. - The window is wall-clock. Infrai clamps
expires_secondsto[1..604800]and returnsSTORAGE_INVALID_TTLoutside it; an expired link fails withAccessDenied/ “Request has expired”, which looks nothing like a CORS fault and gets misfiled as one constantly. - A client’s success callback proves nothing. Only a
headproves the object landed. - There’s no hook. A presigned PUT stores bytes; it doesn’t resize them, strip EXIF, or reject a 40 MB HEIC pretending to be a 12 KB JPEG.
That last one is the real argument. Thumbnails are derived data, and derived data should be produced by code you control, from an original you’ve already validated.
Upload the original once, derive the rest server-side
The shape that works: the client sends one file, your worker fans it out. Infrai signs each slot for free, so the only billable events are the writes themselves.
import sharp from "sharp";
const API = "https://api.infrai.cc";
const BUCKET = "kb-thumbs-0726";
const SIZES = [320, 1024];
const token = process.env.INFRAI_API_KEY;
if (!token) throw new Error("INFRAI_API_KEY is not set");
const auth = { Authorization: `Bearer ${token}`, "Content-Type": "application/json" };
export async function deriveVariants(originalKey) {
const src = await fetch(`${API}/v1/storage/object/get/${BUCKET}/${originalKey}`, { method: "GET", headers: auth });
const body = await src.json();
if (!src.ok || body.ok === false) throw new Error(body?.error?.code ?? `HTTP ${src.status}`);
if (!body.data.found) throw new Error(`original missing: ${originalKey}`);
const original = Buffer.from(body.data.data_base64, "base64");
const stem = originalKey.replace(/^originals\//, "").replace(/\.[^.]+$/, "");
const written = [];
for (const width of SIZES) {
const webp = await sharp(original).resize({ width }).webp({ quality: 82 }).toBuffer();
const key = `derived/${stem}/${width}.webp`;
const slotRes = await fetch(`${API}/v1/storage/object/presign/${BUCKET}/${key}`, {
method: "POST",
headers: auth,
body: JSON.stringify({ op: "put", expires_seconds: 300, content_type: "image/webp", max_bytes: 5_000_000 }),
});
const slot = await slotRes.json();
if (!slotRes.ok || slot.ok === false) throw new Error(`presign ${key}: ${slot?.error?.code ?? slotRes.status}`);
const put = await fetch(slot.data.url, { method: slot.data.method, headers: slot.data.headers ?? {}, body: webp });
if (!put.ok) throw new Error(`variant ${width} rejected: HTTP ${put.status}`);
written.push({ key, bytes: webp.length });
}
return written;
}
Two details that matter more than they look. The variant key is derived from the original key, never from client input — a filename with an unescaped ../ in it is how one tenant ends up writing into another’s prefix. And max_bytes is enforced by the signature rather than by your handler, so it holds even if the worker is the thing that’s buggy.
Let the bucket tell you when to derive
Polling a prefix is wasteful. Subscribe instead, and the derive job starts when the original actually lands:
export INFRAI_API_KEY="your_infrai_api_key"
curl -sS -X POST https://api.infrai.cc/v1/storage/bucket/set_notification/kb-thumbs-0726 \
-H "Authorization: Bearer ${INFRAI_API_KEY}" \
-H "Content-Type: application/json" \
-d '{"events":["object.created"],"target":{"url":"https://example.com/hooks/thumbnails"}}'
{
"ok": true,
"data": { "subscription_id": "stnf_1a67caedb66ac1353e945259" }
}
Treat that callback as a hint, not as truth — re-read the object from your own worker before you trust anything in the payload.
Confirm the variant set exists
Listing a prefix is free, and it’s the check to put in a smoke test:
curl -sS "https://api.infrai.cc/v1/storage/object/list/kb-thumbs-0726?prefix=derived/tenant_7/img_9f2a3c/" \
-H "Authorization: Bearer ${INFRAI_API_KEY}"
{
"ok": true,
"data": {
"items": [
{ "key": "derived/tenant_7/img_9f2a3c/1024.webp", "size_bytes": 14, "etag": "1b1eb4b1", "last_modified": "2026-07-26T00:36:12Z" },
{ "key": "derived/tenant_7/img_9f2a3c/320.webp", "size_bytes": 13, "etag": "6c9d3a80", "last_modified": "2026-07-26T00:36:11Z" }
],
"next_cursor": null
}
}
What the fan-out costs
Presigning, head, list and bucket setup are free and rate-limited, and they don’t draw down the new-account trial credit. Writes are the billable part: verified 26 July 2026, storage.object.put is $0.0001 per call and storage.object.get $0.0002, with stored bytes and egress metered on top. A two-size set costs one read plus two writes, so about $0.0004 per original — the sharp CPU time will dominate that long before the API bill does.
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')]"
Rates here trend down and campaigns run, so the live figure may well be below the one printed above. The durable part isn’t the rate anyway: the same key that signs these slots runs the queue the derive job sits on, the cron that expires old variants, and the error tracker that catches the 40 MB HEIC — one account, one invoice, per-tenant cost as a query rather than a reconciliation.
When the browser really does have to upload directly
| Option | Cross-origin PUT from a page | Who edits the CORS rules | Reach for it when |
|---|---|---|---|
| Infrai presign + your server | No | Nobody — cors_rules is read-only | Derived assets, or any upload you were going to validate anyway |
| Amazon S3 | Yes | You | Large user-selected files, proxy-less by requirement |
| Cloudflare R2 | Yes | You | Same, with egress you’d rather not pay for |
| MinIO, self-hosted | Yes | You | On-prem or air-gapped |
| Cloudinary | Yes, and it derives too | The vendor | Image transforms are the product, not a side quest |
If proxy-less browser upload is a hard requirement — a video tool, a bulk photo importer — stick with S3 or R2 and don’t fight this. For a thumbnail pipeline, the CORS gap costs you nothing you weren’t already spending, because the original had to pass through validation regardless.