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 storage host answers 403 AccessForbidden with no Access-Control-Allow-Origin, and the body is never sent — your signature is never examined. On Infrai you can write the rules today (POST /v1/storage/bucket/set_cors/{bucket} stores them and GET /v1/storage/bucket/get/{bucket} reads them back), but the storage host does not yet answer that preflight with them, so a cross-origin PUT from a tab still doesn’t complete.

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.

Worth doing once so you stop suspecting your own code: store a rule set and watch the preflight unchanged.

curl -sS -X POST https://api.infrai.cc/v1/storage/bucket/set_cors/kb-thumbs-0726 \
  -H "Authorization: Bearer ${INFRAI_API_KEY}" \
  -H "Content-Type: application/json" \
  -d '{"rules":[{"allowed_origins":["https://app.example.com"],"allowed_methods":["PUT","GET"],"allowed_headers":["*"],"max_age_seconds":3600}]}'

The call returns 200 with the rules echoed, and bucket/get reports them from then on — the control plane holds them, the storage host has not started serving them. Which means the answer for a thumbnail pipeline isn’t “wait for it”: it’s that the browser shouldn’t be uploading derived images anyway.

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_bytes when you presign, or a client that lies about Content-Length writes whatever it likes into your prefix.
  • The window is wall-clock. Infrai clamps expires_seconds to [1..604800] and returns STORAGE_INVALID_TTL outside it; an expired link fails with AccessDenied / “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 head proves 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", "content_type": "image/webp", "metadata": null, "last_modified": "2026-07-26T00:36:12Z" },
      { "key": "derived/tenant_7/img_9f2a3c/320.webp", "size_bytes": 13, "etag": "6c9d3a80", "content_type": "image/webp", "metadata": null, "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 cheap and per call: storage.object.put is $0.0001, verified 2026-07-27.

Reads are the line that pays for this whole design, because they’re metered by volume rather than per call — storage.object.get bills $0.104 per GB. Deriving a 40 KB 320px WebP means every later view moves about a hundredth of the bytes the 4 MB original would have, which is a bigger saving than any per-call rate could ever be. That is the thing worth remembering when someone proposes serving originals with a width= attribute and calling it done.

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 POST /v1/queue/publish the derive job sits on, the POST /v1/cron/create that expires old variants and the POST /v1/errors/capture that catches the 40 MB HEIC are already on the same key that signs these slots — no second account, no second bill, and per-tenant cost is one GET /v1/account/usage rather than a reconciliation.

When the browser really does have to upload directly

OptionCross-origin PUT from a pageWho edits the CORS rulesReach for it when
Infrai presign + your serverNoYou, via set_cors — the host doesn’t answer preflights with them yetDerived assets, or any upload you were going to validate anyway
Amazon S3YesYouLarge user-selected files, proxy-less by requirement
Cloudflare R2YesYouSame, with egress you’d rather not pay for
MinIO, self-hostedYesYouOn-prem or air-gapped
CloudinaryYes, and it derives tooThe vendorImage 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.

References

Browse more storage developer guides