The three-route Express pattern for delegated browser uploads

Sign, receive, verify. Where authorisation actually has to live when a browser uploads straight to object storage, with a complete Node 22 Express implementation.

Three routes carry this pattern: one that authorises a request and mints an upload slot, one the client calls when its PUT finishes, and one that confirms the claim against the bucket instead of believing it. Everything that looks like security has to happen in the first route, because a presigned URL enforces nothing about who is using it. Infrai’s POST /v1/storage/object/presign/{bucket}/{key} is the minting call, and the rest is your Express app.

Before the code, one constraint that changes the topology. Infrai’s storage API exposes no CORS configuration route, and a real browser preflight against a presigned upload URL returns 403 — we checked. So a browser cannot PUT directly into an Infrai bucket today; the same three-route design works, but the bytes go through your Express process, or the browser leg targets Cloudflare R2 or S3 where you can set a CORS rule and Infrai handles everything after the upload. That’s a real limitation rather than a setting you’ve missed, so pick deliberately and don’t ship a client-side fetch that can’t pass preflight.

Set the bucket up once

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":"kbg-direct-0726","region":"eu-central-1","acl":"private"}'

curl -sS -X POST https://api.infrai.cc/v1/storage/bucket/set_lifecycle/kbg-direct-0726 \
  -H "Authorization: Bearer ${INFRAI_API_KEY}" \
  -H "Content-Type: application/json" \
  -d '{"rules":[{"prefix":"pending/","expire_days":1}]}'

The lifecycle rule is the cheapest garbage collector you’ll ever write: anything that lands under pending/ and never gets promoted disappears in a day. Minimum window is one day, so it’s a backstop rather than a scheduler.

What the sign route must check

The signature covers the object key, the HTTP verb, the expiry and — for uploads — the content type. It says nothing about identity. Everything in this table is your job, and skipping any row turns the endpoint into an open write proxy:

CheckWhere it goesWhat breaks without it
Session or bearer authExpress middlewareAnonymous writes into your bucket
Key derived from the session, never from the requestpending/${userId}/${uuid}One user overwrites another’s object
Content-type allowlistcontent_type in the presign bodyHTML uploaded and served back to your users
Size capmax_bytes in the presign bodyA 4 GB upload on your storage bill
Per-user mint quotaCounter in Redis or your databaseEnumeration of slots, unbounded cost
Short TTLexpires_secondsA slot that stays live in someone’s history

Here’s what the presign call itself returns for an upload, which is what the client has to honour exactly:

{
  "ok": true,
  "data": {
    "url": "https://infrai-1333115350.cos.ap-singapore.myqcloud.com/a4ee0c441fa36c267.kbg-direct-0726/uploads/u_8412/receipt2.pdf?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Expires=300&X-Amz-SignedHeaders=content-type%3Bhost&X-Amz-Signature=36e55331...",
    "method": "PUT",
    "headers": { "Content-Type": "application/pdf" },
    "fields": null,
    "expires_at": "2026-07-26T05:11:11.042494Z",
    "max_bytes": 10485760
  }
}

X-Amz-SignedHeaders includes content-type, so the client must send back exactly the header in headers. Send a different one — or let a HTTP library guess — and the upload 403s.

The Express app

import express from "express";
import { randomUUID } from "node:crypto";

const API = "https://api.infrai.cc";
const BUCKET = "kbg-direct-0726";
const KEY = process.env.INFRAI_API_KEY;
if (!KEY) throw new Error("INFRAI_API_KEY is not set");

const ALLOWED = new Map([["application/pdf", "pdf"], ["image/png", "png"], ["image/jpeg", "jpg"]]);
const MAX_BYTES = 10 * 1024 * 1024;
const auth = { authorization: `Bearer ${KEY}`, "content-type": "application/json" };
const slots = new Map();

const app = express();
app.use(express.json({ limit: "64kb" }));

function requireUser(req, res, next) {
  const userId = req.get("x-demo-user");           // replace with your session lookup
  if (!userId) return res.status(401).json({ error: "unauthenticated" });
  req.userId = userId;
  next();
}

app.post("/uploads/sign", requireUser, async (req, res) => {
  const contentType = String(req.body?.content_type ?? "");
  if (!ALLOWED.has(contentType)) return res.status(415).json({ error: "content type not allowed" });

  const slotId = randomUUID();
  const objectKey = `pending/${req.userId}/${slotId}.${ALLOWED.get(contentType)}`;
  try {
    const r = await fetch(`${API}/v1/storage/object/presign/${BUCKET}/${objectKey}`, {
      method: "POST",
      headers: auth,
      body: JSON.stringify({ op: "put", expires_seconds: 300, content_type: contentType, max_bytes: MAX_BYTES }),
    });
    if (!r.ok) throw new Error(`presign ${r.status}: ${await r.text()}`);
    const { data } = await r.json();
    slots.set(slotId, { userId: req.userId, objectKey, expiresAt: data.expires_at });
    res.json({ slot_id: slotId, url: data.url, method: data.method, headers: data.headers, max_bytes: data.max_bytes });
  } catch (err) {
    await fetch(`${API}/v1/errors/capture`, {
      method: "POST",
      headers: auth,
      body: JSON.stringify({ title: "presign failed", message: String(err), level: "error", user_id: req.userId }),
    }).catch(() => {});
    res.status(502).json({ error: "could not issue an upload slot" });
  }
});

app.post("/uploads/:slotId/complete", requireUser, async (req, res) => {
  const slot = slots.get(req.params.slotId);
  if (!slot || slot.userId !== req.userId) return res.status(404).json({ error: "no such slot" });

  const head = await fetch(`${API}/v1/storage/object/head/${BUCKET}/${slot.objectKey}`, { headers: auth });
  const meta = await head.json();
  if (!meta?.data?.found) return res.status(409).json({ error: "nothing was uploaded" });
  if (meta.data.size_bytes > MAX_BYTES) return res.status(413).json({ error: "too large" });

  slots.delete(req.params.slotId);
  res.json({ ok: true, key: slot.objectKey, size_bytes: meta.data.size_bytes, etag: meta.data.etag });
});

app.listen(3000, () => console.log("listening on :3000"));

The completion route is the whole point of the design. A client can call it without ever uploading, twice, or with someone else’s slot id, and each of those is handled by asking the bucket rather than trusting the caller. Head costs nothing and returns found, size_bytes, etag and the stored content type — enough to accept or reject.

Proving the browser leg by hand

PUT_URL=$(curl -sS -X POST \
  "https://api.infrai.cc/v1/storage/object/presign/kbg-direct-0726/pending/u_8412/demo.pdf" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}" \
  -H "Content-Type: application/json" \
  -d '{"op":"put","expires_seconds":300,"content_type":"application/pdf","max_bytes":10485760}' \
  | python3 -c "import json,sys; print(json.load(sys.stdin)['data']['url'])")

curl -sS -o /dev/null -w "upload %{http_code}\n" \
  -X PUT -H 'Content-Type: application/pdf' --data-binary @invoice.pdf "$PUT_URL"

Then confirm from the server side, exactly as the completion route does:

curl -sS "https://api.infrai.cc/v1/storage/object/head/kbg-direct-0726/uploads/u_8412/receipt.pdf" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}"

A client that vanishes mid-flow never calls /complete, which is why the pending/ lifecycle rule exists and why a nightly sweep over GET /v1/storage/object/list/{bucket} is worth having. Bucket notifications don’t cover this gap — they don’t fire for presigned uploads at all, only for server-side writes.

Cost and the honest boundary

Presign, head, list, lifecycle and bucket creation are free and rate-limited. The billed calls in this flow are object/put at $0.0001 when the bytes go through your server, object/get at $0.0002 for a proxied read, and errors/capture at $0.00005. New accounts get $2 free credit. Verified 2026-07-26; rates on this API have moved down over time, so read the live values:

curl -sS "https://api.infrai.cc/v1/discovery" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}" | \
  python3 -c "import json,sys; d=json.load(sys.stdin); print([(c['id'], c['billing'].get('price_usd')) for c in d['capabilities'] if c['id'].startswith(('storage.','errors.'))])"

If browser-direct is non-negotiable, use Cloudflare R2 or S3 for the upload leg — they have CORS configuration and Infrai doesn’t, and no amount of clever routing works around a failed preflight. MinIO is the right answer if the files can’t leave your own network. What you get by staying here is that the slot table, the error capture above, the queue job that processes the file and the invoice for all three sit behind one credential, and the second question — “now email the user their receipt” — needs no new vendor.

References

Browse more storage developer guides