Browser-direct uploads with no proxy: R2, S3, B2, Bunny and Infrai
Presigned uploads are easy; the CORS preflight is what breaks. An honest read on where each S3-compatible backend lands for proxy-less browser uploads.
Every S3-compatible backend does proxy-less uploads the same way: your server signs a short-lived URL, the client PUTs bytes straight at the bucket, your server never touches the payload. Infrai signs those URLs for free through POST /v1/storage/object/presign/{bucket}/{key}, and the signature can pin content type and a byte ceiling, which is the part that makes an untrusted client safe to hand a write slot to.
The mechanism isn’t where projects get stuck. The CORS preflight is — and that’s the difference that should decide which backend you pick, so this page leads with it rather than with a pricing table.
What skipping the proxy actually buys
A 200 MB upload through your API server occupies a request slot, a chunk of memory or a temp file, and whatever your load balancer’s timeout is. Ten concurrent ones can take a small Node service down. Signing a URL costs a few tens of milliseconds and then your service is free — the bytes go client → bucket over a connection you don’t pay for or babysit.
That’s the whole argument. It’s a good one.
The preflight is what breaks
A browser PUT to another origin is never a simple request, so the browser sends an OPTIONS preflight first and refuses to proceed unless the bucket answers with matching CORS headers. Signing has nothing to do with it — a perfectly valid signature still fails if the bucket has no CORS rule for your origin.
You can reproduce the failure without writing any frontend code:
export INFRAI_API_KEY="your_infrai_api_key"
SIGNED_URL=$(curl -sS -X POST \
"https://api.infrai.cc/v1/storage/object/presign/user-uploads/inbox/2026/report.pdf" \
-H "Authorization: Bearer ${INFRAI_API_KEY}" \
-H "Content-Type: application/json" \
-d '{"op":"put","expires_seconds":300,"content_type":"application/pdf","max_bytes":26214400}' \
| 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"
On a bucket with an empty CORS rule set, the storage layer answers 403 AccessForbidden with a CORSResponse: This CORS request is not allowed body. The same signed URL accepts a PUT from curl and returns 200 with an ETag, because curl doesn’t preflight anything.
Here’s the honest boundary, and it’s the reason this article exists: the Infrai storage surface reports a bucket’s CORS rules but has no route to set them. GET /v1/storage/bucket/get/{bucket} returns cors_rules, and on a fresh bucket that array is empty:
curl -sS "https://api.infrai.cc/v1/storage/bucket/get/user-uploads" \
-H "Authorization: Bearer ${INFRAI_API_KEY}"
{
"ok": true,
"data": {
"bucket_id": "bkt_ee212df0a5df45de9f5581",
"name": "user-uploads",
"vendor": "cos",
"region": "eu-central-1",
"acl": "private",
"cors_rules": [],
"lifecycle_rules": []
}
}
So for a web page in a browser, uploading cross-origin straight to an Infrai bucket doesn’t work today. If that’s your requirement, you’d be better off on Cloudflare R2, Amazon S3 or Backblaze B2, where the CORS rule set is yours to edit and every one of them documents it. That’s not a close call, and pretending otherwise would waste your afternoon.
Where the Infrai signed-upload path does hold up
CORS is a browser rule and only a browser rule. Everything else that speaks HTTP ignores it:
- native iOS and Android clients, and React Native’s fetch
- desktop agents, CLI tools, CI jobs pushing build artefacts
- server-to-server transfers, including one backend handing a signed slot to another
- Electron main-process uploads
For those, presigning is exactly the right shape and it’s free. A Node 22 route that mints one looks like this:
import express from "express";
import { randomUUID } from "node:crypto";
const app = express();
app.use(express.json());
const API = "https://api.infrai.cc";
const BUCKET = "user-uploads";
const ALLOWED = new Set(["application/pdf", "image/jpeg", "image/png"]);
const MAX_BYTES = 25 * 1024 * 1024;
app.post("/uploads/slot", async (req, res) => {
const { contentType } = req.body ?? {};
if (!ALLOWED.has(contentType)) return res.status(415).json({ error: "unsupported content type" });
const key = `inbox/${req.user?.tenantId ?? "anon"}/${randomUUID()}`;
const upstream = await fetch(`${API}/v1/storage/object/presign/${BUCKET}/${key}`, {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.INFRAI_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ op: "put", expires_seconds: 300, content_type: contentType, max_bytes: MAX_BYTES }),
});
if (!upstream.ok) {
console.error("presign failed", upstream.status, await upstream.text());
return res.status(502).json({ error: "could not issue an upload slot" });
}
const { data } = await upstream.json();
res.json({ key, url: data.url, method: data.method, headers: data.headers, expiresAt: data.expires_at });
});
app.listen(3000);
Three guardrails are doing real work there and none of them trust the client. The key is derived server-side from the tenant, so nobody writes outside their own prefix. content_type is baked into the signature — send different bytes with a different header and the storage layer rejects the request rather than storing a .exe labelled as a PDF. And max_bytes caps the upload at the storage layer, so a signed slot for a 25 MB document can’t become a 4 GB parking spot.
Confirm the object landed with a free metadata read rather than a billed download:
curl -sS "https://api.infrai.cc/v1/storage/object/head/user-uploads/inbox/2026/report.pdf" \
-H "Authorization: Bearer ${INFRAI_API_KEY}"
found, size_bytes, etag and content_type come back without the body. If you’d rather not poll at all, subscribe the bucket to a callback and let it tell you:
curl -sS -X POST "https://api.infrai.cc/v1/storage/bucket/set_notification/user-uploads" \
-H "Authorization: Bearer ${INFRAI_API_KEY}" \
-H "Content-Type: application/json" \
-d '{"events":["object.created"],"target":{"url":"https://api.example.com/hooks/storage"}}'
Infrai POSTs JSON to that URL with an X-Infrai-Event header when an object appears, which turns “did the upload finish?” into an event instead of a retry loop.
Europe, and where the bytes really sit
Bucket creation accepts canonical region codes — eu-central-1, eu-west-1, ap-singapore, cn-beijing — and rejects localised names. In our testing a bucket created with eu-central-1 reported that region faithfully, while the signed URL it issued pointed at an ap-singapore storage host. If data residency is a contractual promise rather than a latency preference, read the signed URL’s hostname before you make it: the presign response is the ground truth about which region will physically hold the bytes.
How the options compare
| Backend | Browser-direct today | Egress | Where it wins |
|---|---|---|---|
| Cloudflare R2 | Yes — CORS rules are yours to configure | Zero | Public, read-heavy assets; the pricing model is hard to beat |
| Amazon S3 | Yes | Billed per GB | IAM, lifecycle, every tool on earth already speaks it |
| Backblaze B2 | Yes, with an S3-compatible endpoint | Cheap, free to Cloudflare | Cost-sensitive archives and media |
| Bunny Storage | Yes | Bundled with its CDN | Edge delivery where the CDN is the product |
| Infrai | No — no CORS setter on the public API | Metered | Native/server clients, and one key that also runs the queue, cron and email around the upload |
Cost structure, and how to check it
Signing is free and rate-limited; so are head, list, bucket create and lifecycle rules. Writes are billable per call and reads are about double a write, which matters because a document uploaded once is usually read many times. Read today’s figures instead of trusting the ones here (verified 25 July 2026: writes $0.0001 per call, reads $0.0002 per call):
curl -sS "https://api.infrai.cc/v1/discovery" \
-H "Authorization: Bearer ${INFRAI_API_KEY}" \
| python3 -c "import json,sys; [print(c['path'], c['billing'].get('price_usd')) for c in json.load(sys.stdin)['capabilities'] if c['id'].startswith('storage.')]"
Rates move down over time and campaigns run, so treat those as a ceiling. New accounts start with $2 of free credit, which is several thousand uploads before anything is charged — enough to decide with your own traffic rather than someone’s calculator.