Delegating uploads without shipping cloud keys to the client
The signed-upload handshake explained by trust boundary: what your server keeps, what the signature really gates, and exactly where the browser leg stops.
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, what the signature is actually load-bearing for: on Infrai it is the access boundary on a private bucket, not decoration over an otherwise open path. Second, the browser leg needs a CORS rule the storage host acts on, and that is the half that isn’t there yet — which is why the working shape below is a signed PUT from a server or a native client, not from a tab.
Who holds what
Draw the trust boundary before you write any code. Three parties, three different levels of trust:
| Actor | Holds | Can do |
|---|---|---|
| Browser / mobile client | A short-lived URL for one key | One PUT of one object, until the clock runs out |
| Your API | The Infrai key, in an env var | Decide who may write, where, and how big |
| Infrai | The vendor credentials | Everything, 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 acceptsPUT; aGETagainst it fails. - One deadline. After
expires_secondsthe URL returnsSTORAGE_PRESIGN_EXPIRED. Five minutes is plenty for a slot the user is about to fill. - One shape.
content_typeis folded into the signature andmax_bytesis enforced by the storage layer, so a slot for a 25 MB PDF can’t quietly become a 4 GB parking spot.
Check that the signature is the boundary, don’t assume it
This is the assumption worth spending one curl on, because the answer decides whether the object key is a secret or merely a name. Take a presigned GET URL, cut everything from the ? onwards, and ask for the object anyway:
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"
We ran that against a private bucket on 27 July 2026 and got 403. The identical request with the query string intact returns 200 and the bytes. So on a signed-only bucket the signature is the access control, and an object path that leaks into a log line is not by itself a disclosure.
That is not a licence to pass signed URLs around. The URL is a bearer token: whoever holds it, until the clock runs out, is the user. Two habits survive the good news. Derive keys server-side from a UUID or a hash rather than inbox/user-17/avatar.png, so a compromised link tells an attacker nothing about the next one. And keep expires_seconds in the low hundreds, because the shorter the window, the less a URL pasted into a support ticket is worth by the time anyone reads 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. The missing piece isn’t the rule set — POST /v1/storage/bucket/set_cors/{bucket} accepts an allowed-origins policy, returns 200, and GET /v1/storage/bucket/get/{bucket} reads the same rules straight back. The storage host simply doesn’t answer preflights from them yet, so the browser stops before it ever sends your correctly signed PUT. Control plane yes, data plane not yet, and no mode: "no-cors" trick recovers a usable response.
If a page in a tab must upload straight to the bucket, that’s the one requirement here you’d be better off buying elsewhere: put that leg on Cloudflare R2 or S3, where the object host applies the bucket’s CORS configuration, and keep the rest of the workflow where it is. Splitting one capability out is a far smaller cost than splitting a stack.
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. Two lines on the data path are billable, and they are not counted in the same unit — which is the part people model wrong.
Writes are per call: verified 27 July 2026, PUT /v1/storage/object/put/{bucket}/{key} costs $0.0001 however large the object is.
Reads are per byte. GET /v1/storage/object/get/{bucket}/{key} meters the response body at $0.104 per GB on the same reading, so a request count tells you nothing about that line. What moves it is which rendition you serve: a 40 KB preview and the 25 MB original are the same one call and about six hundred times apart on the bill. Design the read path around handing back the smallest thing that answers the question, and reserve the original for the person who explicitly asked for it. 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'), c['billing'].get('unit')) for c in json.load(sys.stdin)['capabilities'] if c['id'].startswith('storage.object')]"
Print the unit alongside the figure, always. That second column is what tells you whether a route is metered per call or per gigabyte, and reading the number without it is how a cost model quietly goes wrong. New accounts carry $2 of free credit, which is enough to run the handshake against your own traffic before you decide anything.
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. Whatever happens after the upload is already on the same key that minted the slot: POST /v1/queue/publish hands the object to a worker, POST /v1/cron/create schedules the sweep that deletes slots nobody filled, POST /v1/email/send tells the user it processed, and POST /v1/errors/capture catches the case where the PDF turns out to be a renamed ZIP. No second account, no second vendor, one bill — and per-tenant cost attribution is a query rather than four exports stapled together in a spreadsheet.