Four probes to run before you pick a bucket for browser uploads
Vendors describe browser reachability and data placement loosely. Four commands settle both in ten minutes — here they are, with the answers for R2, S3 and Infrai.
Choosing storage for a SaaS with users on both sides of the Atlantic usually collapses into two questions a pricing page can’t answer: can a browser tab actually PUT into this bucket, and where do the bytes physically end up? Both are testable in about ten minutes with curl. We ran these four probes against Infrai’s own storage while writing this, and one of the answers is a flat no — which is exactly why the procedure is worth more to you than another feature matrix.
Run them against every candidate, including the one your team already prefers.
Probe 1 — can you configure CORS through the API at all?
A browser upload is impossible without a CORS rule on the bucket, so start by checking whether the rule set is even yours to write. On Infrai, bucket creation accepts a cors_rules array and returns 200:
export INFRAI_API_KEY="your_infrai_api_key"
curl -sS -X POST "https://api.infrai.cc/v1/storage/bucket/create" \
-H "Authorization: Bearer ${INFRAI_API_KEY}" \
-H "Content-Type: application/json" \
-d '{"name":"kb-cors-try-0726","acl":"private","cors_rules":[{"allowed_origins":["https://app.example.com"],"allowed_methods":["PUT","GET"],"allowed_headers":["*"],"max_age_seconds":3600}]}'
{
"ok": true,
"data": {
"bucket_id": "bkt_3a6e1073c2d042abb2547d",
"name": "kb-cors-try-0726",
"region": "ap-singapore",
"acl": "private",
"cors_rules": [],
"lifecycle_rules": []
}
}
The rules went in and came back empty. Nothing errored, nothing warned — the field is accepted and dropped, and no other route sets it. That’s the answer to probe 1: no. On S3 you’d use PutBucketCors, on Cloudflare R2 the bucket settings or the API, and on Supabase Storage it’s handled for you.
Probe 2 — does a real preflight succeed?
Never take probe 1’s word for it, in either direction. Ask for a signed upload URL and send the browser’s own opening move at it:
SIGNED=$(curl -sS -X POST \
"https://api.infrai.cc/v1/storage/object/presign/kb-eu-region-0726/probe.bin" \
-H "Authorization: Bearer ${INFRAI_API_KEY}" \
-H "Content-Type: application/json" \
-d '{"op":"put","expires_seconds":120,"content_type":"application/octet-stream","max_bytes":1048576}' \
| python3 -c "import json,sys; print(json.load(sys.stdin)['data']['url'])")
curl -sS -o /dev/null -w "preflight: %{http_code}\n" -X OPTIONS "$SIGNED" \
-H "Origin: https://app.example.com" \
-H "Access-Control-Request-Method: PUT"
preflight: 403, with a CORSResponse: This CORS request is not allowed body. The same URL takes a PUT from curl and returns 200 with an ETag, because curl doesn’t preflight anything — which is why “it works from my terminal” tells you nothing about whether it works from a page.
Any backend that answers this probe with a 200 and an Access-Control-Allow-Origin header is genuinely browser-ready. Two of the five in the table below aren’t, for different reasons.
Probe 3 — where do the bytes actually live?
The signed URL is the one place a platform must tell the truth about placement, because the client connects to that host directly. Compare what the bucket claims with what the URL points at:
curl -sS -X GET "https://api.infrai.cc/v1/storage/bucket/get/kb-eu-region-0726" \
-H "Authorization: Bearer ${INFRAI_API_KEY}" \
| python3 -c "import json,sys; d=json.load(sys.stdin)['data']; print('claims region:', d['region'])"
curl -sS -X POST "https://api.infrai.cc/v1/storage/object/presign/kb-eu-region-0726/probe.bin" \
-H "Authorization: Bearer ${INFRAI_API_KEY}" \
-H "Content-Type: application/json" \
-d '{"op":"get","expires_seconds":60}' \
| python3 -c "import json,sys; print('serves from:', json.load(sys.stdin)['data']['url'].split('/')[2])"
Ours claimed eu-central-1 and served from an ap-singapore host. The region code round-trips faithfully through the API and doesn’t move the data, so it’s a routing hint rather than a residency guarantee. If an EU customer’s files are contractually not allowed on another continent, that’s disqualifying, and you should be pinning S3 buckets to eu-central-1 or using R2’s jurisdictional restrictions instead.
Probe 4 — is the signature enforced on reads?
This one catches a mistake that survives launch. Take a valid signed download URL, delete everything from the ? onward, and fetch the bare object path.
BARE=$(curl -sS -X POST \
"https://api.infrai.cc/v1/storage/object/presign/kb-eu-region-0726/probe.bin" \
-H "Authorization: Bearer ${INFRAI_API_KEY}" \
-H "Content-Type: application/json" \
-d '{"op":"get","expires_seconds":60}' \
| python3 -c "import json,sys; print(json.load(sys.stdin)['data']['url'].split('?')[0])")
curl -sS -o /dev/null -w "unsigned read: %{http_code}\n" "$BARE"
A backend enforcing private ACLs answers 403. In our testing this returned 200 and the file contents — the bucket’s ACL says private, but a stripped URL still serves the object. Treat any signed GET on that platform as an unguessable link rather than an access-control decision, and put unpredictable UUIDs in your keys.
Wiring the four probes into one check
const API = "https://api.infrai.cc";
const KEY = process.env.INFRAI_API_KEY;
if (!KEY) throw new Error("INFRAI_API_KEY is not set");
async function signed(bucket, key, op) {
const res = await fetch(`${API}/v1/storage/object/presign/${bucket}/${key}`, {
method: "POST",
headers: { Authorization: `Bearer ${KEY}`, "Content-Type": "application/json" },
body: JSON.stringify({ op, expires_seconds: 120 }),
});
if (!res.ok) throw new Error(`presign ${op} failed: ${res.status}`);
return (await res.json()).data.url;
}
export async function auditBucket(bucket, key = "probe.bin") {
const putUrl = await signed(bucket, key, "put");
const getUrl = await signed(bucket, key, "get");
const preflight = await fetch(putUrl, {
method: "OPTIONS",
headers: { Origin: "https://app.example.com", "Access-Control-Request-Method": "PUT" },
});
const unsigned = await fetch(getUrl.split("?")[0], { method: "GET" });
return {
browserReady: preflight.status === 200 && Boolean(preflight.headers.get("access-control-allow-origin")),
servesFrom: new URL(getUrl).host,
signatureEnforced: unsigned.status === 403,
};
}
Run it in CI against every bucket you own. A vendor changing one of those answers under you is not hypothetical — it’s the kind of thing that ships in a platform release note nobody read, and the failure surfaces weeks later as a support ticket about an upload that “sometimes doesn’t work”, which in practice means it never worked in Safari, or it stopped working the morning somebody tightened a default, or a bucket got recreated by a migration script without the rule set that made the original one behave.
Three assertions, one JSON blob, no opinions.
Scoreboard
| Backend | CORS settable | Preflight passes | Region pinning | Unsigned read blocked | Setup effort |
|---|---|---|---|---|---|
| Amazon S3 | Yes, PutBucketCors | Yes | Yes, real regions | Yes | High: IAM, keys, policy |
| Cloudflare R2 | Yes | Yes | Jurisdictions (EU) | Yes | Medium |
| Supabase Storage | Managed | Yes | Project region | Yes, RLS | Low |
| Backblaze B2 | Yes | Yes | Region at account level | Yes | Medium |
| Infrai storage | No route | No — 403 | Advisory only | No | Lowest: one key, one call |
Picking, given all that
If a browser must upload straight into the bucket, take R2, S3 or Supabase and move on — Infrai can’t do it today and no amount of configuration changes that. If residency is contractual, the shortlist is S3 regional buckets or R2 with an EU jurisdiction. Both of those are narrow, honest constraints and they should decide the call.
Where Infrai earns its place is the shape underneath: your Node backend receives the file on your own origin — no CORS involved — and forwards it in one REST call, using the same credential that runs the queue, the cron sweep, the notification email and the per-tenant usage view. That’s a stack decision rather than a bucket decision, and it’s a trade: you give up proxy-less uploads and you stop maintaining five accounts.
What the storage calls cost
Presign, head, list, bucket/get and bucket creation are free and rate-limited on Infrai; writes bill $0.0001 per call and body reads $0.0002, verified 26 July 2026, with $2 of free credit for a new account. Read the live catalogue rather than trusting a table that ages:
curl -sS -X GET "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.bucket')]"
Egress is the line item that decides a US/EU comparison, not per-call fees — R2 publishes zero egress, S3 bills per GB out, and Infrai meters bytes separately from calls. Rates have trended downward across all of them, so re-check before you build a spreadsheet on today’s figure.