Presigned PUT, private bucket, signed download: what Express really needs

A browser PUT to an Infrai bucket stops at the CORS preflight. The Express and React path that ships instead, the signed download that follows, and what each leg costs.

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 S3 and on 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 is fine, and this page proves it rather than asserting it.

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>

POST /v1/storage/bucket/set_cors/{bucket} takes an allowed-origin rule set and GET /v1/storage/bucket/get/{bucket} reads it back on the bucket record. The signed object endpoint sits a layer further out, though, and it answers the preflight above with 403 and no Access-Control-Allow-* header whatever the bucket record says. If proxy-less browser upload is a hard requirement, you’d be better off on S3 or R2, where the rule set is attached to the object endpoint itself — 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"} />;
}

On the read side, the signature is the boundary

Here’s the part most guides assert instead of checking. 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}'

Fetch that URL and the bytes come back. Now delete everything from the ? onwards and fetch the bare object path: 403. Alter one character of the signature: 403. Let it outlive expires_seconds: 403 with Request has expired in the body. Sign a key nothing was ever written to and the same URL answers 404 — a valid signature over a missing object is a different failure from a bad signature, and the two codes are worth teaching your support rota apart. POST /v1/storage/object/set_acl/{bucket}/{key} refuses public-read outright with STORAGE_ACL_INVALID, so there’s no setting that quietly makes a bucket anonymously readable.

What a signature can’t know is who is holding it. A presigned GET is a bearer token for one object until it expires, on every object store that offers them — so keep the TTL to what the page actually needs, put a UUID in the key so prefixes 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

BackendBrowser PUT worksYou control CORSSignature enforced on readRest of the stack
Amazon S3YesYes, bucket CORS configYes, block public accessIAM, and every tool speaks it
Cloudflare R2YesYesYesZero egress, great for public reads
Supabase StorageYesManaged for youYes, RLS policiesBundled with Postgres and auth
InfraiNo — preflight 403Rules stored on the bucket recordYes, unsigned requests get 403One 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: POST /v1/queue/publish to hand the new object to a worker, POST /v1/errors/capture when that worker throws, and POST /v1/email/send to tell the user it’s ready — all already on the same account, with no second vendor to onboard.

What the calls cost, and how to read today’s number

Signing, head, list and bucket management are free and rate-limited. The two billable routes are metered in different units, which is the thing to internalise before you model this. PUT /v1/storage/object/put/{bucket}/{key} is counted at $0.0001 per call. GET /v1/storage/object/get/{bucket}/{key} is measured at $0.104 per GB of response body, so a read isn’t one unit of anything — it’s however many megabytes you handed back, and the lever is smaller renditions rather than fewer requests. Both verified 27 July 2026 against the live catalogue, and new accounts start with $2 of trial credit. 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 GET /v1/account/usage is the metered truth your invoice is built from, per capability and per tenant if you tag them, which is where to look instead of estimating. Call fees are rarely the dominant term anyway; bytes at rest are metered separately and will outweigh them for anything image-shaped.

References

Browse more storage developer guides