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. POST /v1/storage/bucket/set_cors/{bucket} accepts an allowed-origin rule set and GET /v1/storage/bucket/get/{bucket} reads it back, but a real browser preflight against a presigned upload URL still answers 403 with no Access-Control-Allow-* header — we checked. So the tab can’t PUT into an Infrai bucket today, and the middle leg of this design belongs in your Express process. That’s a boundary to design around rather than a setting you’ve missed, so pick deliberately and don’t ship a client-side fetch that has to survive a 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:
| Check | Where it goes | What breaks without it |
|---|---|---|
| Session or bearer auth | Express middleware | Anonymous writes into your bucket |
| Key derived from the session, never from the request | pending/${userId}/${uuid} | One user overwrites another’s object |
| Content-type allowlist | content_type in the presign body | HTML uploaded and served back to your users |
| Size cap | max_bytes in the presign body | A 4 GB upload on your storage bill |
| Per-user mint quota | Counter in Redis or your database | Enumeration of slots, unbounded cost |
| Short TTL | expires_seconds | A 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. Two different units meet in this flow, and mixing them up is how upload budgets go wrong. Writes are counted: object/put is $0.0001 per call when the bytes go through your server, and errors/capture is $0.00005 per call. Reads are measured: object/get is billed on egress volume at $0.104 per GB, so what drives that line is the size of what you hand back, not how many times a link is clicked — a 40 KB thumbnail and a 40 MB original are three orders of magnitude apart on the same route. New accounts get $2 of trial credit. Verified 2026-07-27; 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 you need a true page-to-bucket upload, this isn’t a good fit for that one leg: run it on Cloudflare R2 or S3, where the CORS rule set reaches the object host and a preflight is answered. MinIO is the right answer if the files can’t leave your own network. What you get by staying here is the leg after the upload: the POST /v1/errors/capture above, the POST /v1/queue/publish that hands the file to a worker, and POST /v1/email/send for “now send the user their receipt” are already on the same key as the presign call — no second account, no second vendor, and one usage view that attributes all four to a tenant.