Why a presigned PUT avatar upload dies at the CORS preflight

The browser sends OPTIONS before your PUT, the bucket answers 403, and the signature was never the problem. What Infrai buckets do at preflight, and the avatar path that works.

Your PUT never leaves the tab. The browser sends an OPTIONS preflight first, the bucket answers 403 AccessForbidden, and the upload dies before a single byte moves — which is why the same signed URL works perfectly from curl. On Infrai, buckets come back with an empty cors_rules list and the storage API has no route that writes one, so an avatar upload should go through your own API rather than straight from the page.

That’s the short answer. The longer one is worth reading, because the failure looks exactly like a signature bug and beginners spend hours re-checking their key instead of reading the OPTIONS response. Everything below was run against api.infrai.cc on 26 July 2026.

Reproduce it in two commands

Sign an upload URL first:

export INFRAI_API_KEY="your_infrai_api_key"

curl -sS -X POST \
  "https://api.infrai.cc/v1/storage/object/presign/avatars-demo/u/usr_8412/avatar-512.png" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}" \
  -H "Content-Type: application/json" \
  -d '{"op":"put","expires_seconds":600,"content_type":"image/png","max_bytes":1048576}'
{
  "ok": true,
  "data": {
    "url": "https://infrai-1333115350.cos.ap-singapore.myqcloud.com/a4ee0c441fa36c267.avatars-demo/u/usr_8412/avatar-512.png?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Expires=600&X-Amz-SignedHeaders=content-type%3Bhost&X-Amz-Signature=224b5850815cca46",
    "method": "PUT",
    "headers": { "Content-Type": "image/png" },
    "fields": null,
    "expires_at": "2026-07-26T00:49:20.670728Z",
    "max_bytes": 1048576
  }
}

Now do by hand what Chrome does before it will touch that URL from a page:

UPLOAD_URL="$(cat upload-url.txt)"

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"
<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.avatars-demo/u/usr_8412/avatar-512.png</Resource>
</Error>

There’s your 403 — from the storage vendor, not from Infrai, and it arrives before any signature is checked.

Why curl succeeds and the page doesn’t

Two things have to be true for a cross-origin upload to work, and they’re independent. The signature has to be valid, and the bucket has to publish a CORS rule that whitelists your origin, the PUT method and the content-type request header. curl skips the second one entirely because it doesn’t implement the same-origin policy; it just sends the request. In our testing the identical URL returned 200 OK with an ETag from a shell, seconds after the browser was refused.

You also can’t dodge the preflight by being clever. A PUT with Content-Type: image/png is not a “simple” request under the CORS spec — only text/plain, multipart/form-data and application/x-www-form-urlencoded are safelisted — and the signed URL above pins X-Amz-SignedHeaders=content-type;host, so dropping the header breaks the signature instead.

The rules live on the bucket. Read them:

curl -sS "https://api.infrai.cc/v1/storage/bucket/get/avatars-demo" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}"
{
  "ok": true,
  "data": {
    "bucket_id": "bkt_973e55b735d242a290973c",
    "name": "avatars-demo",
    "vendor": "cos",
    "region": "eu-central-1",
    "acl": "private",
    "created_at": "2026-07-26T00:38:20.700032Z",
    "cors_rules": [],
    "lifecycle_rules": []
  }
}

GET /v1/storage/bucket/get/{bucket} reads cors_rules, and nothing in the storage surface writes them — Infrai doesn’t support editing bucket CORS today. An empty list is a deny-all list, which is exactly what the preflight reported.

The avatar path that actually ships

Avatars are small, and that changes the calculus. Crop client-side to 512×512, encode to PNG or WebP, and you’re typically holding 80–300 KB — small enough that a JSON round-trip through your own API costs nothing you’ll notice and removes the browser-to-bucket leg completely.

export async function uploadAvatar(blob) {
  if (blob.size > 1_048_576) throw new Error("crop smaller: 1 MB limit");
  const buf = await blob.arrayBuffer();
  const b64 = btoa(String.fromCharCode(...new Uint8Array(buf)));

  const res = await fetch("/api/avatar", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ data: b64, type: blob.type }),
  });
  if (!res.ok) throw new Error(`avatar upload failed: ${res.status}`);
  return res.json();
}

Your handler is the only thing that talks to storage, so there is no origin, no preflight and no CORS rule to configure. This is Node 22, ESM, no dependencies:

import { createServer } from "node:http";

const API = "https://api.infrai.cc";
const BUCKET = "avatars-demo";
const token = process.env.INFRAI_API_KEY;
if (!token) throw new Error("INFRAI_API_KEY is not set");

const ALLOWED = new Set(["image/png", "image/webp", "image/jpeg"]);

async function storeAvatar(userId, dataBase64, contentType) {
  if (!ALLOWED.has(contentType)) throw new Error(`unsupported type ${contentType}`);
  const key = `u/${userId}/avatar-512.${contentType.split("/")[1]}`;
  const payload = { data_base64: dataBase64, content_type: contentType };

  const res = await fetch(`${API}/v1/storage/object/put/${BUCKET}/${key}`, {
    method: "PUT",
    headers: { Authorization: `Bearer ${token}`, "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, etag: out.data.etag, size: out.data.size_bytes };
}

createServer(async (req, res) => {
  if (req.method !== "POST" || req.url !== "/api/avatar") { res.writeHead(404).end(); return; }
  const chunks = [];
  for await (const c of req) chunks.push(c);
  try {
    const { data, type } = JSON.parse(Buffer.concat(chunks).toString());
    const stored = await storeAvatar("usr_8412", data, type);
    res.writeHead(201, { "content-type": "application/json" }).end(JSON.stringify(stored));
  } catch (err) {
    res.writeHead(400, { "content-type": "application/json" })
      .end(JSON.stringify({ error: String(err.message ?? err) }));
  }
}).listen(3000);

PUT /v1/storage/object/put/{bucket}/{key} takes the bytes as base64 in the JSON body. It’s documented for small files and not recommended above 1 MB — for anything bigger, sign a URL and upload from your server, or use multipart. Confirm the object landed:

curl -sS \
  "https://api.infrai.cc/v1/storage/object/head/avatars-demo/u/usr_8412/avatar-512.png" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}"

US and EU, honestly

POST /v1/storage/bucket/create takes a region from a fixed list — us-east-1, us-west-2, eu-west-1, eu-central-1, ap-southeast-1 and a few more. A caveat we hit while testing: a bucket created with eu-central-1 reported that region back on bucket/get, but the signed URL it produced pointed at an ap-singapore vendor host. If EU residency is a contractual promise rather than a preference, read the host out of the presign response and confirm it before you sign anything.

Four ways to get an avatar into a bucket

ApproachPreflight involvedWorks on Infrai todayGood for
Browser → signed PUT, directYes — and it 403sNoNothing, until a CORS setter exists
Browser → your API → object/put base64NoYesAvatars and other sub-1 MB images
Browser → your API → signed PUT from the serverNoYesFiles too big for a JSON body
Browser → Amazon S3 / Cloudflare R2 / MinIO, directYes, and you configure itn/aTeams whose hard requirement is a proxy-less upload

If row four is you, take it. S3 and R2 let you write a CORS policy in a console or a Terraform block, MinIO does the same on your own hardware, and Supabase wraps the whole flow in a client SDK. That’s a real advantage for one job. The trade-off is that you’re then running an object store alongside whatever else your app needs, each with its own key and invoice — the same Infrai key that stored this avatar also sends the confirmation email, runs the nightly cleanup cron and captures the exception when the crop fails.

What the calls cost

Signing, head, list, bucket reads and bucket creation are free and rate-limited. Writes are cheap and reads are dearer: verified 26 July 2026, storage.object.put is $0.0001 per call and storage.object.get is $0.0002 per call, with stored bytes and egress metered separately. Ten thousand avatar uploads is a dollar in call fees. Read today’s numbers rather than trusting this paragraph:

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

Rates here move down over time and campaigns run, so what you see may be lower than what’s printed above.

Limits worth knowing

Upload a type the bucket rejects and you get STORAGE_CONTENT_TYPE_BLOCKED, which reads like a permissions problem and isn’t. Signed URLs expire — a link older than its expires_seconds returns STORAGE_PRESIGN_EXPIRED. There’s no server-side resizing, so the 512×512 crop is your job or another capability’s. And buckets are private or signed-only; there’s no permanent public URL to paste into an <img> tag, which is a deliberate choice and occasionally an annoying one.

References

Browse more storage developer guides