Delegating uploads without shipping cloud keys to the client

The signed-upload handshake explained by trust boundary: what your server keeps, what the signature delegates, and the two things a signature never protects.

The pattern has a name and three actors. Your client asks your own API for permission to write one file; your API — the only process that holds a storage credential — mints a short-lived URL scoped to a single key and a single method; the client sends the bytes to that URL and your API never touches the payload. Infrai mints those URLs at POST /v1/storage/object/presign/{bucket}/{key}, and that call is free.

Two things get left out of most write-ups of this pattern, and both matter more than the plumbing. First, a signature delegates far less than people assume — it is a time-boxed permission slip, not an access-control system. Second, the browser leg needs a CORS rule on the bucket, and Infrai has no route that writes one, so if your requirement is literally a page in a tab uploading cross-origin, Cloudflare R2 or Amazon S3 is the honest answer today.

Who holds what

Draw the trust boundary before you write any code. Three parties, three different levels of trust:

ActorHoldsCan do
Browser / mobile clientA short-lived URL for one keyOne PUT of one object, until the clock runs out
Your APIThe Infrai key, in an env varDecide who may write, where, and how big
InfraiThe vendor credentialsEverything, on your behalf

The credential never crosses the first boundary. That’s the whole security claim of the pattern, and it holds up — a leaked signed URL costs you one object, a leaked cloud key costs you the account.

Minting a slot

Here’s the request your server makes. Nothing in it comes from the client except the declared content type, which you validate against an allowlist first:

export INFRAI_API_KEY="your_infrai_api_key"

curl -sS -X POST \
  "https://api.infrai.cc/v1/storage/object/presign/app-uploads/inbox/2026/07/f8a1c2.pdf" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}" \
  -H "Content-Type: application/json" \
  -d '{"op":"put","expires_seconds":300,"content_type":"application/pdf","max_bytes":26214400}'

The response carries the URL, the method the client must use, and the headers it has to send verbatim:

{
  "ok": true,
  "data": {
    "url": "https://infrai-1333115350.cos.ap-singapore.myqcloud.com/a4ee0c441fa36c267.app-uploads/inbox/2026/07/f8a1c2.pdf?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Expires=300&X-Amz-SignedHeaders=content-type%3Bhost&X-Amz-Signature=0b857793f47fb54e",
    "method": "PUT",
    "headers": { "Content-Type": "application/pdf" },
    "fields": null,
    "expires_at": "2026-07-26T01:04:28.689953Z",
    "max_bytes": 26214400
  }
}

A Node 22 handler that issues one, with the key derived on the server so a caller can’t write into somebody else’s prefix:

import { createServer } from "node:http";
import { randomUUID } from "node:crypto";

const KEY = process.env.INFRAI_API_KEY;
if (!KEY) throw new Error("INFRAI_API_KEY is not set");

const BUCKET = "app-uploads";
const ALLOWED = new Map([
  ["application/pdf", "pdf"],
  ["image/jpeg", "jpg"],
  ["image/png", "png"],
]);

async function issueSlot(tenantId, contentType) {
  const ext = ALLOWED.get(contentType);
  if (!ext) throw new Error(`content type ${contentType} is not accepted`);
  const objectKey = `inbox/${tenantId}/${randomUUID()}.${ext}`;

  const res = await fetch(
    `https://api.infrai.cc/v1/storage/object/presign/${BUCKET}/${objectKey}`,
    {
      method: "POST",
      headers: { Authorization: `Bearer ${KEY}`, "Content-Type": "application/json" },
      body: JSON.stringify({
        op: "put",
        expires_seconds: 300,
        content_type: contentType,
        max_bytes: 25 * 1024 * 1024,
      }),
    },
  );
  const out = await res.json();
  if (!res.ok || out.ok === false) throw new Error(out?.error?.code ?? `HTTP ${res.status}`);
  return { objectKey, url: out.data.url, method: out.data.method, headers: out.data.headers };
}

createServer(async (req, res) => {
  if (req.method !== "POST") { res.writeHead(405).end(); return; }
  const chunks = [];
  for await (const c of req) chunks.push(c);
  try {
    const { contentType } = JSON.parse(Buffer.concat(chunks).toString());
    const slot = await issueSlot("t_4471", contentType);
    res.writeHead(200, { "content-type": "application/json" }).end(JSON.stringify(slot));
  } catch (err) {
    res.writeHead(400, { "content-type": "application/json" })
      .end(JSON.stringify({ error: String(err.message ?? err) }));
  }
}).listen(3000);

What the signature actually delegates

Four things, and it’s worth being precise because each one is a guardrail you’d otherwise have to build:

  • One key. The path is signed, so the holder can’t rename the object into a prefix they shouldn’t reach.
  • One method. op: "put" produces a URL that only accepts PUT; a GET against it fails.
  • One deadline. After expires_seconds the URL returns STORAGE_PRESIGN_EXPIRED. Five minutes is plenty for a slot the user is about to fill.
  • One shape. content_type is folded into the signature and max_bytes is enforced by the storage layer, so a slot for a 25 MB PDF can’t quietly become a 4 GB parking spot.

The part that isn’t security

Now the uncomfortable bit, and the reason this article doesn’t stop at the happy path.

On Infrai, the signature governs writes. It does not gate reads. Take any presigned GET URL, cut everything from the ? onwards, and the object still comes back — we ran exactly that against a private bucket on 26 July 2026 and got 200 OK with the bytes:

curl -sS -o /dev/null -w '%{http_code}\n' \
  "https://infrai-1333115350.cos.ap-singapore.myqcloud.com/a4ee0c441fa36c267.app-uploads/inbox/2026/07/f8a1c2.pdf"

So treat the object URL as unguessable rather than protected. Two habits follow. Derive keys from a UUID or a hash server-side — never inbox/user-17/avatar.png, which anyone can enumerate. And keep expiry short anyway, because the shorter the window the less likely a URL ends up pasted into a support ticket. If you need reads that are genuinely denied to an unauthenticated fetcher, that’s a limitation you should design around: put your own API in front of the download and stream through it.

The browser leg, honestly

A cross-origin PUT isn’t a simple request, so a browser sends an OPTIONS preflight first and refuses to continue without matching CORS headers on the response. Reproduce the failure with no frontend at all:

curl -sS -i -X OPTIONS \
  "https://infrai-1333115350.cos.ap-singapore.myqcloud.com/a4ee0c441fa36c267.app-uploads/inbox/2026/07/f8a1c2.pdf" \
  -H "Origin: https://app.example.com" \
  -H "Access-Control-Request-Method: PUT"

That comes back 403 with no Access-Control-Allow-Origin, because the bucket’s rule set is empty and there’s no API route to fill it. R2, S3, MinIO and Supabase Storage all let you edit CORS in a console or a config file; if a page in a tab must upload straight to the bucket, use one of them for that leg and don’t fight this.

Where the same handshake works fine today: native iOS and Android clients, React Native, Electron, CLI tools, CI runners and server-to-server transfers. None of them implement the same-origin policy, so none of them preflight. In our testing a signed PUT from curl returned 200 with an ETag in well under a second for a 200 KB file.

What it costs, and how to check

Presigning is free and rate-limited. So are GET /v1/storage/object/head/{bucket}/{key}, GET /v1/storage/object/list/{bucket} and bucket management. You pay per call on the data path: verified 26 July 2026, PUT /v1/storage/object/put/{bucket}/{key} is $0.0001 and a read through GET /v1/storage/object/get/{bucket}/{key} is $0.0002 — reads cost about double writes, which is the ratio that actually shapes a document workload. Read today’s numbers rather than trusting a paragraph:

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.object')]"

Rates here move downward over time and discount campaigns run, so what you find is as likely to be lower as equal. New accounts carry $2 of free credit, which covers roughly 20,000 writes — enough to test the handshake with your own traffic.

Confirm an upload landed with a free metadata read instead of a billed download:

curl -sS "https://api.infrai.cc/v1/storage/object/head/app-uploads/inbox/2026/07/f8a1c2.pdf" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}"

found, size_bytes, etag and content_type come back without the body — which is also how you verify the client sent what it promised.

The consolidation argument sits underneath all of this. The key that signs the slot also runs the cron sweep that deletes abandoned uploads, sends the notification email when processing finishes, and captures the exception when a PDF turns out to be a renamed ZIP. That’s one account and one bill instead of four.

References

Browse more storage developer guides