Your backend minted a presigned URL and the browser still gets 403
The server's 200 says nothing about what the client will send. How to prove which layer rejected the upload, and the ticket shape that quietly signs the wrong verb.
Because minting and using a presigned URL are two different requests, and only the second one is checked. Your server asked Infrai for a signature and got one; that call can’t fail on the grounds that a browser will later send a header the signature didn’t cover. The 403 is the storage backend recomputing the signature over the request it actually received and getting a different number. Nothing about the mint proves the upload will match.
So stop reading your own logs. The evidence is in the XML body the upload got back.
export INFRAI_API_KEY=your_infrai_api_key
curl -sS -X POST \
"https://api.infrai.cc/v1/storage/object/presign/kb-presign403-0726/lesson-01.mp4" \
-H "Authorization: Bearer ${INFRAI_API_KEY}" \
-H "Content-Type: application/json" \
-d '{"op":"put","expires_seconds":900,"content_type":"video/mp4"}'
{
"ok": true,
"data": {
"url": "https://infrai-1333115350.cos.ap-singapore.myqcloud.com/a4ee0c441fa36c267.kb-presign403-0726/lesson-01.mp4?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Date=20260726T054740Z&X-Amz-Expires=900&X-Amz-SignedHeaders=content-type%3Bhost&X-Amz-Signature=e7dc8bb766...",
"method": "PUT",
"headers": { "Content-Type": "video/mp4" },
"fields": null,
"expires_at": "2026-07-26T06:02:40.932747Z",
"max_bytes": null
}
}
Three fields in that payload are the whole contract, and a frontend that ignores any of them earns a 403: method, headers, and the X-Amz-SignedHeaders list inside the query string. Everything named there participates in the signature. content-type;host means the client must send Content-Type: video/mp4 exactly — not application/octet-stream, not nothing at all.
That last case is the common one, because HTTP clients are helpful. fetch(url, { method: "PUT", body: blob }) sets Content-Type from blob.type, which for a file picked out of an <input> is whatever the OS guessed. Your server signed video/mp4; the browser sends video/quicktime; the signature is recomputed and doesn’t match.
The ticket that signs a download and returns 200
Here’s the one that produces exactly the symptom in the question — a backend that logs success moments before the client fails.
The op field takes get or put, and the enum is enforced, so a creative value is the cheap failure:
curl -sS -X POST \
"https://api.infrai.cc/v1/storage/object/presign/kb-presign403-0726/lesson-01.mp4" \
-H "Authorization: Bearer ${INFRAI_API_KEY}" \
-H "Content-Type: application/json" \
-d '{"op":"upload","expires_seconds":900,"content_type":"video/mp4"}'
{
"ok": false,
"error": {
"code": "INVALID_ARGUMENT",
"http_status": 400,
"message": "storage.object.presign op must be 'get' or 'put'",
"retryable": false
}
}
The expensive one is op: "get" on an upload path — a legal call that mints a legal ticket for the wrong verb. It comes back like this:
{
"ok": true,
"data": {
"url": "https://infrai-1333115350.cos.ap-singapore.myqcloud.com/a4ee0c441fa36c267.kb-presign403-0726/lesson-01.mp4?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Date=20260727T123601Z&X-Amz-Expires=900&X-Amz-SignedHeaders=host&X-Amz-Signature=0d5a79d839...",
"expires_at": "2026-07-27T12:46:01.567866Z"
}
}
Look at what’s missing. No method, no headers, and X-Amz-SignedHeaders collapsed to host — that’s a download signature, and it’s what a helper function with a defaulted op argument hands you. A client that PUTs to it collects SignatureDoesNotMatch every single time while your API’s log says 200.
<?xml version='1.0' encoding='utf-8' ?>
<Error>
<Code>SignatureDoesNotMatch</Code>
<Message>The Signature you specified is invalid.</Message>
<StringToSign>AWS4-HMAC-SHA256
20260726T054755Z
20260726/ap-singapore/s3/aws4_request
c35d82aaff9260d2a9efd5f1014d489a09b9b0be80432d26fe46e968132f1c11</StringToSign>
<CononicalRequest>PUT
/a4ee0c441fa36c267.kb-presign403-0726/lesson-01.mp4
X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Date=20260726T054755Z&X-Amz-Expires=900&X-Amz-SignedHeaders=content-type%3Bhost</CononicalRequest>
</Error>
The CononicalRequest block — the typo is the vendor’s — shows the verb the backend used when it recomputed. If that says PUT and your signature was minted for a download, you’ve found it. Assert on the response instead of trusting it:
const API = "https://api.infrai.cc";
const KEY = process.env.INFRAI_API_KEY;
if (!KEY) throw new Error("INFRAI_API_KEY is not set");
const auth = { authorization: `Bearer ${KEY}`, "content-type": "application/json" };
export async function uploadTicket(bucket, key, contentType, ttlSeconds = 300) {
const res = await fetch(`${API}/v1/storage/object/presign/${bucket}/${key}`, {
method: "POST",
headers: auth,
body: JSON.stringify({ op: "put", expires_seconds: ttlSeconds, content_type: contentType }),
});
if (!res.ok) throw new Error(`presign failed: HTTP ${res.status} ${await res.text()}`);
const { data } = await res.json();
// A put ticket always carries a verb and a header map. If either is absent the
// signature is a download signature and no client can make it work.
if (!data.method || !data.headers) {
throw new Error(`presign returned a download ticket for an upload: ${JSON.stringify(data)}`);
}
const signed = new URL(data.url).searchParams.get("X-Amz-SignedHeaders") ?? "";
return { url: data.url, method: data.method, headers: data.headers, mustEcho: signed.split(";") };
}
export async function pushBytes(ticket, bytes) {
const put = await fetch(ticket.url, { method: ticket.method, headers: ticket.headers, body: bytes });
if (put.status === 403) throw new Error(`signature rejected: ${await put.text()}`);
if (!put.ok) throw new Error(`upload failed: HTTP ${put.status}`);
return put.headers.get("etag");
}
Ship mustEcho to the client and have it fail loudly when a header on that list isn’t in the map it’s about to send. It turns a silent 403 into a build-time-ish assertion.
Which layer said no
A 403 and a 404 come from different code, and knowing which one you got removes most of the guesswork.
| What the client saw | What it means | Where to look |
|---|---|---|
403 + SignatureDoesNotMatch | Signature recomputed differently | Headers echoed, verb, query string mutation |
403 + Request has expired with a <ServerTime> | The window really closed | Compare ServerTime to your stored expires_at |
404 + NoSuchKey | Signature was accepted; there’s no object | The write never landed, or the key differs |
403 on an OPTIONS request | Cross-origin preflight | Move the upload leg to your server — see below |
400 + STORAGE_INVALID_TTL | Mint rejected before signing | expires_seconds outside [1..604800] |
The third row is the one worth internalising. Signature verification runs before the object lookup, so a good signature over a key nobody ever wrote answers 404, not 403. That means a 403 is always a statement about the request, never about the file.
Expiry is what everyone suspects first and it’s rarely the answer when the gap is seconds, but it’s cheap to rule out:
SHORT_URL=$(curl -sS -X POST \
"https://api.infrai.cc/v1/storage/object/presign/kb-presign403-0726/lesson-01.mp4" \
-H "Authorization: Bearer ${INFRAI_API_KEY}" \
-H "Content-Type: application/json" \
-d '{"op":"put","expires_seconds":1,"content_type":"video/mp4"}' \
| python3 -c "import json,sys; print(json.load(sys.stdin)['data']['url'])")
python3 -c "import time; time.sleep(4)"
curl -sS -X PUT -H 'Content-Type: video/mp4' --data-binary @lesson-01.mp4 "${SHORT_URL}"
<?xml version='1.0' encoding='utf-8' ?>
<Error>
<Code>AccessDenied</Code>
<Message>Request has expired</Message>
<ServerTime>2026-07-26T05:48:23Z</ServerTime>
<Resource>/a4ee0c441fa36c267.kb-presign403-0726/lesson-01.mp4</Resource>
</Error>
ServerTime is the signing authority’s clock, and it’s the only way to settle a skew argument. If it sits inside the window you recorded and you still got this, your app server’s clock is the one that’s wrong.
Verb binding deserves a line of its own, because it bites tooling rather than users. A signature covers the method, so a HEAD against a URL minted for get returns 403 while a plain GET returns 200 — we measured both. Uptime probes, link checkers and headless renderers all do this: a Puppeteer job that probes an asset before page.goto, or a Gotenberg render pulling a remote image into a PDF, will report a broken link that works perfectly in a browser.
The browser case you can’t fix from the API
Point a real web page at a presigned PUT and the browser sends an OPTIONS preflight first. The bucket answers 403 with no Access-Control-Allow-Origin header, so the fetch never reaches the PUT — and the console shows a CORS error rather than the 403, which is how this ends up filed as a frontend bug.
POST /v1/storage/bucket/set_cors/{bucket} will take an origin list and store it — bucket/get reads the same array back — but the preflight above still answers 403 from the storage host, so browser-to-bucket upload is not something to design around today. That’s a boundary, not a configuration you’ve missed, and the fix in your own code is small: have the page POST to your API and let your API do the object/put, which also gives you the size and content-type check you wanted anyway. If a web page must upload without touching your server at all, S3 with a CORSConfiguration or Cloudflare R2 with CORS rules on the bucket are the right choice — buy one of those for that bucket and keep the rest where it is. Native apps, desktop helpers, CLI tools and your own backend are unaffected, since CORS is a browser policy and nothing else enforces it.
Two things the signature is and isn’t
It is the whole of the object’s protection: strip the query string off a signed download and the storage host answers 403, so a private object stays private. What it isn’t is an identity check — the URL is a bearer token until it expires, so the authorisation decision belongs in the route that mints it, and short TTLs are how you bound the blast radius of a leaked link.
max_bytes is the other one worth knowing, because it changes the ticket shape. Ask for a cap and you get a form POST back instead of a PUT URL, with a signed policy carrying a content-length-range; we minted one at 5 bytes, pushed 40, and got a 403 with nothing stored. Copy the returned fields through to whatever sends the bytes, and confirm afterwards:
curl -sS -X POST \
"https://api.infrai.cc/v1/storage/object/presign/kb-presign403-0726/lesson-01.mp4" \
-H "Authorization: Bearer ${INFRAI_API_KEY}" \
-H "Content-Type: application/json" \
-d '{"op":"put","expires_seconds":0}'
curl -sS "https://api.infrai.cc/v1/storage/object/head/kb-presign403-0726/lesson-01.mp4" \
-H "Authorization: Bearer ${INFRAI_API_KEY}"
The first returns 400 STORAGE_INVALID_TTL with the allowed range spelled out; the second returns {"found": true, "size_bytes": 14, "etag": "...", "content_type": "video/mp4"} and costs nothing.
Cost, and why the signing lives here
Minting is free and rate-limited, and so are head and list — you can put a presign call behind every click without watching a meter. Proxying bytes through GET /v1/storage/object/get/{bucket}/{key} bills on a different meter: $0.104 per GB of egress, verified 2026-07-27, so the cost of the proxy hop scales with what you move rather than with how often you’re asked. Read the live figure rather than this sentence:
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')) for c in json.load(sys.stdin)['capabilities'] if c['id'].startswith('storage.object')])"
If presigning is all you need, the AWS SDK signs locally, costs nothing per URL, and gives you condition keys and bucket policies that this surface doesn’t support — stick with it. What you get by minting here is that the same credential also runs the queue that processes the upload, the error capture around the failure, and the usage query that tells you which tenant it belongs to.