Browser uploads of private documents: the CORS wall and the way round
Why a signed upload URL can't be used from a tab on Infrai today, the Node relay that works instead, and the Postgres row that tracks each upload.
Short version, because it saves you a day: you can mint a signed upload URL on Infrai and PUT to it from a server, but not from a browser tab. The signed host answers a CORS preflight with 403 and sends no Access-Control-Allow-Origin, and there is no support for setting bucket CORS rules anywhere in the storage API. Uploads therefore go browser → your Node service → PUT /v1/storage/object/put/{bucket}/{key}, with Postgres holding the state of each one.
That’s a smaller loss than it sounds. Your service was going to validate the file anyway, and the download side — signed links straight from storage — is unaffected.
Test the wall yourself
Sign a slot, then ask the host what it thinks of your origin:
export INFRAI_API_KEY="your_infrai_api_key"
SIGNED_URL=$(curl -sS -X POST \
"https://api.infrai.cc/v1/storage/object/presign/kb-portalup-0726/tenants/tnt_88/contracts/2026-07/9f3c1a.pdf" \
-H "Authorization: Bearer ${INFRAI_API_KEY}" \
-H "Content-Type: application/json" \
-d '{"op":"get","expires_seconds":300}' \
| python3 -c "import json,sys; print(json.load(sys.stdin)['data']['url'])")
curl -sS -o /dev/null -w 'preflight: %{http_code}\n' -X OPTIONS \
-H "Origin: https://app.example.com" \
-H "Access-Control-Request-Method: PUT" "${SIGNED_URL}"
curl -sS -D - -o /dev/null -H "Origin: https://app.example.com" "${SIGNED_URL}" | grep -i 'access-control' || echo "no CORS headers"
preflight: 403
no CORS headers
Chrome and Firefox stop at the first line. Nothing you write in JavaScript gets past that, and no mode: "no-cors" trick recovers a usable response.
The relay, in about forty lines
The browser sends the file to your own origin, which already has cookies, sessions and CSRF handled:
async function uploadContract(file, documentId) {
const res = await fetch(`/api/documents/${documentId}/content`, {
method: "PUT",
headers: { "Content-Type": file.type || "application/octet-stream" },
body: file,
credentials: "same-origin",
});
if (!res.ok) throw new Error(`upload failed: ${res.status}`);
return res.json();
}
const input = document.querySelector("#contract");
input.addEventListener("change", async () => {
const file = input.files[0];
if (!file) return;
if (file.size > 25 * 1024 * 1024) {
alert("Contracts must be under 25 MB");
return;
}
const stored = await uploadContract(file, input.dataset.documentId);
console.log("stored as", stored.key);
});
The server side takes the raw body, writes it to storage, verifies it, and flips a database row:
import express from "express";
import { Pool } from "pg";
import { requireSession } from "./session.mjs";
const API = "https://api.infrai.cc";
const BUCKET = "kb-portalup-0726";
const MAX_BYTES = 25 * 1024 * 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" };
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
const app = express();
app.put("/api/documents/:id/content", requireSession, express.raw({ type: "*/*", limit: MAX_BYTES }), async (req, res) => {
const { rows } = await pool.query(
"SELECT storage_key, status FROM uploads WHERE id = $1 AND tenant_id = $2",
[req.params.id, req.session.tenantId],
);
if (!rows.length) return res.status(404).json({ error: "unknown upload" });
if (rows[0].status === "stored") return res.status(409).json({ error: "already uploaded" });
const key = rows[0].storage_key;
const payload = { data_base64: req.body.toString("base64"), content_type: req.get("content-type") };
try {
const put = await fetch(`${API}/v1/storage/object/put/${BUCKET}/${key}`, { method: "PUT", headers: auth, body: JSON.stringify(payload) });
const written = await put.json();
if (!put.ok || written.ok === false) throw new Error(written?.error?.code ?? `put HTTP ${put.status}`);
const check = await fetch(`${API}/v1/storage/object/head/${BUCKET}/${key}`, { method: "GET", headers: auth });
const meta = (await check.json()).data;
if (!meta?.found || meta.size_bytes !== req.body.length) throw new Error("size mismatch after write");
await pool.query(
"UPDATE uploads SET status = 'stored', size_bytes = $1, etag = $2, stored_at = now() WHERE id = $3",
[meta.size_bytes, meta.etag, req.params.id],
);
res.json({ key, size_bytes: meta.size_bytes, etag: meta.etag });
} catch (err) {
console.error("relay upload failed", err);
await pool.query("UPDATE uploads SET status = 'failed' WHERE id = $1", [req.params.id]);
res.status(502).json({ error: "storage unavailable" });
}
});
app.listen(3000);
Note what the route never touches: the filename the browser sent. The key was decided when the row was created, server-side.
The row comes first
CREATE TABLE uploads (
id uuid PRIMARY KEY,
tenant_id text NOT NULL,
storage_key text NOT NULL UNIQUE,
filename text NOT NULL,
status text NOT NULL DEFAULT 'pending' CHECK (status IN ('pending','stored','failed')),
size_bytes bigint,
etag text,
created_at timestamptz NOT NULL DEFAULT now(),
stored_at timestamptz
);
CREATE INDEX uploads_stale_idx ON uploads (created_at) WHERE status = 'pending';
Rows are created pending by a small POST that allocates the key; the PUT above promotes them to stored. Anything still pending an hour later is an abandoned upload — a user who closed the tab — and a nightly job clears it. Without that partial index you’d be scanning the whole table to find them.
Handing the document back
Downloads are the easy half, and they don’t touch your bandwidth:
curl -sS -X POST \
"https://api.infrai.cc/v1/storage/object/presign/kb-portalup-0726/tenants/tnt_88/contracts/2026-07/9f3c1a.pdf" \
-H "Authorization: Bearer ${INFRAI_API_KEY}" \
-H "Content-Type: application/json" \
-d '{"op":"get","expires_seconds":300}'
{
"ok": true,
"data": {
"url": "https://infrai-1333115350.cos.ap-singapore.myqcloud.com/a4ee0c441fa36c267.kb-portalup-0726/tenants/tnt_88/contracts/2026-07/9f3c1a.pdf?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Expires=300&X-Amz-Signature=3068114abe6ecdeace0c1fbc6298bb7c",
"expires_at": "2026-07-26T01:18:12.118982Z"
}
}
expires_seconds runs from 1 to 604800 and an out-of-range value returns STORAGE_INVALID_TTL. Do the ownership check in the route that mints this link — stripping the signature off the URL still returns the object, so the timer is the only thing the signature buys. There’s more on that trust boundary in our private upload architecture guide.
Files too big for one request
Above roughly 25 MB, buffering the whole body in your service stops being reasonable. Multipart splits it into parts of at least 5 MiB, and your server streams each part straight to a signed part URL:
curl -sS -X POST "https://api.infrai.cc/v1/storage/multipart/create/kb-portalup-0726" \
-H "Authorization: Bearer ${INFRAI_API_KEY}" \
-H "Content-Type: application/json" \
-d '{"key":"tenants/tnt_88/contracts/2026-07/deposition.pdf","content_type":"application/pdf"}'
multipart/create and presign_part are free; upload_part is billed per part and complete per call. If the user vanishes mid-upload, DELETE /v1/storage/multipart/abort/{upload_id} releases the parts — wire it to the same job that clears stale pending rows.
The three transports, compared
| Transport | Works from a browser today | Your bandwidth | Best for |
|---|---|---|---|
| Browser → bucket, signed PUT | No — preflight 403, no CORS route | None | Not available here |
Browser → your API → object/put | Yes | Full file | Documents up to ~25 MB |
| Server → signed PUT slot | Yes, server-side only | None | Files your backend already holds |
What the upload path costs
Verified 26 July 2026: storage.object.put is $0.0001 per call, storage.multipart.upload_part $0.0001 per part and storage.multipart.complete $0.0002, while presign, presign_part, head, list, multipart/create and multipart/abort are free and rate-limited and don’t spend the $2 of trial credit on a new account. Storage rent and egress are metered separately. Pull today’s numbers with:
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.multipart') or c['id'].startswith('storage.object')]"
Storage rates drift down and campaigns run, so the live figure is likely lower than the one printed here, and GET /v1/account/usage is what your bill is built from. The reason to keep documents on the same account as the rest of your stack isn’t the per-call rate — it’s that the antivirus queue, the OCR job, the notification email and the audit log all run on the same key with one usage view.
If browser-direct is a hard requirement
Then this isn’t a good fit for that leg, and the honest recommendation is Cloudflare R2 or S3, where you set a CORS policy on the bucket and the tab uploads directly. Supabase Storage does the same with a client library on top. You can also run both: uploads direct to R2, everything else — jobs, mail, models, the documents themselves after processing — on Infrai. Splitting one capability out is a much smaller cost than splitting your whole stack.