Presigned PUT, private bucket, signed download: what Express really needs
A browser PUT to an Infrai bucket dies at the CORS preflight, and a signed GET isn't a permission. The Express and React path that actually ships, with both proofs.
Short answer: your Express server signs a short-lived PUT, React sends the file straight at the bucket, and downloads come back through a second signed URL. That shape is correct on Amazon S3 and on Cloudflare R2. On Infrai only half of it survives contact with a browser today, so this page gives you the mechanism, the proof of where it breaks, and the version that ships.
The download half has a surprise of its own, and it’s the bigger one.
The signature is fine — the preflight is what stops you
Minting the URL is one free call. Nothing about it is unusual, and the response carries everything the client has to echo back:
export INFRAI_API_KEY="your_infrai_api_key"
curl -sS -X POST \
"https://api.infrai.cc/v1/storage/object/presign/kb-express-uploads/uploads/u_1042/receipt.png" \
-H "Authorization: Bearer ${INFRAI_API_KEY}" \
-H "Content-Type: application/json" \
-d '{"op":"put","expires_seconds":900,"content_type":"image/png","max_bytes":10485760}'
{
"ok": true,
"data": {
"url": "https://infrai-1333115350.cos.ap-singapore.myqcloud.com/a4ee...kb-express-uploads/uploads/u_1042/receipt.png?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Expires=900&X-Amz-SignedHeaders=content-type%3Bhost&X-Amz-Signature=73842fca...",
"method": "PUT",
"headers": { "Content-Type": "image/png" },
"fields": null,
"expires_at": "2026-07-26T01:12:13.407411Z",
"max_bytes": 10485760
}
}
Send that URL a PUT from curl and you get a 200 and an ETag. Send it from a page on https://app.example.com and the browser never gets that far: a cross-origin PUT with a Content-Type header is not a simple request, so Chrome fires an OPTIONS preflight first and the bucket has to answer it.
SIGNED_URL=$(curl -sS -X POST \
"https://api.infrai.cc/v1/storage/object/presign/kb-express-uploads/uploads/u_1042/receipt.png" \
-H "Authorization: Bearer ${INFRAI_API_KEY}" \
-H "Content-Type: application/json" \
-d '{"op":"put","expires_seconds":300,"content_type":"image/png","max_bytes":10485760}' \
| 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"
<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>
</Error>
The Infrai storage API reports cors_rules on GET /v1/storage/bucket/get/{bucket} but has no route that sets them, and a rule set passed to bucket create is dropped without an error. So a fresh bucket answers every preflight with 403, and there’s no call you can make to change that. If proxy-less browser upload is a hard requirement, you’d be better off on S3 or R2, where the CORS rule set is yours — that’s a one-time bucket configuration on either.
The route that ships: Express carries the bytes
Twenty lines, no SDK, no cloud credentials anywhere near the client. The browser posts to your own origin, which sidesteps CORS entirely because there’s nothing cross-origin left.
import express from "express";
import { randomUUID } from "node:crypto";
const app = express();
const BUCKET = "kb-express-uploads";
const ALLOWED = new Set(["image/png", "image/jpeg", "application/pdf"]);
app.put("/api/uploads/:kind", express.raw({ type: "*/*", limit: "10mb" }), async (req, res) => {
const contentType = req.get("content-type") ?? "";
if (!ALLOWED.has(contentType)) return res.status(415).json({ error: "unsupported type" });
const tenant = req.get("x-tenant-id") ?? "anon";
const key = `uploads/${tenant}/${randomUUID()}.${contentType.split("/")[1]}`;
const payload = JSON.stringify({
content_base64: req.body.toString("base64"),
content_type: contentType,
});
const upstream = await fetch(`https://api.infrai.cc/v1/storage/object/put/${BUCKET}/${key}`, {
method: "PUT",
headers: {
Authorization: `Bearer ${process.env.INFRAI_API_KEY}`,
"Content-Type": "application/json",
},
body: payload,
});
if (!upstream.ok) {
console.error("storage put failed", upstream.status, await upstream.text());
return res.status(502).json({ error: "upload rejected" });
}
const { data } = await upstream.json();
res.status(201).json({ key: data.key, size: data.size_bytes, etag: data.etag });
});
app.listen(3000);
Two details matter more than they look. The key is built server-side from the tenant, so a client can’t write into somebody else’s prefix no matter what it posts. And the content type is checked before the call, because the storage layer refuses text/html and application/javascript outright with STORAGE_CONTENT_TYPE_BLOCKED — an active-content rule you’ll meet the first time somebody uploads an .html résumé.
React side, no library:
import { useState } from "react";
export function UploadButton({ tenantId }) {
const [status, setStatus] = useState("idle");
async function onChange(event) {
const file = event.target.files?.[0];
if (!file) return;
setStatus("uploading");
try {
const res = await fetch(`/api/uploads/receipt`, {
method: "PUT",
headers: { "Content-Type": file.type, "x-tenant-id": tenantId },
body: file,
});
if (!res.ok) throw new Error(`upload failed: ${res.status}`);
const { key } = await res.json();
setStatus(`stored as ${key}`);
} catch (err) {
setStatus(err.message);
}
}
return <input type="file" accept="image/png,image/jpeg,application/pdf" onChange={onChange} disabled={status === "uploading"} />;
}
A signed download URL is not a permission
Here’s the part most guides skip. Ask for a read URL and you get one:
curl -sS -X POST \
"https://api.infrai.cc/v1/storage/object/presign/kb-express-uploads/uploads/u_1042/receipt.png" \
-H "Authorization: Bearer ${INFRAI_API_KEY}" \
-H "Content-Type: application/json" \
-d '{"op":"get","expires_seconds":600}'
Now delete everything from the ? onwards and fetch the bare object URL. In our testing it returns 200 and the file contents — no signature, no expiry, no 403. The bucket’s ACL says private, and the object still serves to anyone who knows the path.
That’s a limitation worth designing around rather than discovering in an audit. The practical reading: a signed GET on Infrai is an unguessable link with an expiry hint, not an access control decision. Use a UUID in the key so paths can’t be walked, and if a document must be gated on session state, read it through your own server where you can check that state.
import express from "express";
const app = express();
const BUCKET = "kb-express-uploads";
app.get("/api/files/*", async (req, res) => {
const key = req.params[0];
if (!key.startsWith(`uploads/${req.session?.tenantId ?? "�"}/`)) return res.sendStatus(403);
const url = `https://api.infrai.cc/v1/storage/object/get/${BUCKET}/${key}`;
const upstream = await fetch(url, {
method: "GET",
headers: { Authorization: `Bearer ${process.env.INFRAI_API_KEY}` },
});
const body = await upstream.json();
if (!body.ok || !body.data.found) return res.sendStatus(404);
res.type(body.data.content_type ?? "application/octet-stream");
res.send(Buffer.from(body.data.data_base64, "base64"));
});
app.listen(3001);
GET /v1/storage/object/get/{bucket}/{key} hands back {found, status, key, size_bytes, data_base64}, which is easy to stream out and easy to cache. Confirm an upload landed without paying for the body at all:
curl -sS -X GET \
"https://api.infrai.cc/v1/storage/object/head/kb-express-uploads/uploads/u_1042/receipt.png" \
-H "Authorization: Bearer ${INFRAI_API_KEY}"
Where each backend lands
| Backend | Browser PUT works | You control CORS | Signature enforced on read | Rest of the stack |
|---|---|---|---|---|
| Amazon S3 | Yes | Yes, bucket CORS config | Yes, block public access | IAM, and every tool speaks it |
| Cloudflare R2 | Yes | Yes | Yes | Zero egress, great for public reads |
| Supabase Storage | Yes | Managed for you | Yes, RLS policies | Bundled with Postgres and auth |
| Infrai | No — preflight 403 | No setter route | No, objects read without one | One key also runs queues, cron, email, AI |
Pick the row that matches the constraint you can’t move. If it’s “the browser must PUT straight at the bucket”, the first three rows are all fine and Infrai isn’t. If it’s “one credential and one bill for storage plus everything the upload triggers”, the proxy route above costs you a hop and buys the rest of the platform.
What the calls cost, and how to read today’s number
Signing, head, list and bucket management are free and rate-limited. Writes are billable per call: PUT /v1/storage/object/put/{bucket}/{key} was $0.0001 per call and GET /v1/storage/object/get/{bucket}/{key} $0.0002 per call, verified 26 July 2026 against the live catalogue. New accounts start with $2 of free credit, roughly twenty thousand writes before anything is charged. Read the current figures yourself:
curl -sS -X GET "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', 'free')) for c in json.load(sys.stdin)['capabilities'] if c['id'].startswith('storage.')]"
Two honest notes on those numbers. Rates drift downward and campaigns run, so treat the figures above as a ceiling rather than a quote — and when we compared the catalogue rate against GET /v1/account/usage for a few thousand real reads, the billed amount came in lower than the list price. Per-call fees are rarely the dominant term anyway; stored bytes and egress are metered separately and will outweigh them for anything image-shaped.