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. POST /v1/storage/bucket/set_cors/{bucket} stores an allowed-origin rule set and the bucket record reads it back, yet the signed host answers a CORS preflight with 403 and sends no Access-Control-Allow-Origin. 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. Strip the query string off that URL and the object answers 403 — the signature is what the store checks, which is the property this whole design leans on. Do the ownership check in the route that mints the link anyway, because that route is the only place that knows who’s asking; the signature proves a link was issued, not who’s holding it now. There’s more on deriving keys server-side 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

TransportWorks from a browser todayYour bandwidthBest for
Browser → bucket, signed PUTNo — the preflight 403sNoneNot available here
Browser → your API → object/putYesFull fileDocuments up to ~25 MB
Server → signed PUT slotYes, server-side onlyNoneFiles your backend already holds

What the upload path costs

Verified 27 July 2026: storage.object.put is $0.0001 per call, storage.multipart.upload_part the same per part and storage.multipart.complete $0.0002, while presign, presign_part, head, list, multipart/create and multipart/abort are free and rate-limited. Handing a document back is the one line billed by volume rather than by call — storage.object.get is metered at $0.104 per GB of response body — so an archive of 2 MB invoices and one of 60 MB scanned depositions behave nothing alike, however many times each is opened. Storage rent is metered per GB-month on top. 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 rate anyway — it’s that the next step is already there. POST /v1/queue/publish sends the file to the antivirus and OCR workers, POST /v1/errors/capture records the ones that fail, and POST /v1/email/send tells the counterparty it’s ready to sign. Same key, one usage view, no second account.

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.

References

Browse more storage developer guides