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 op value that 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 op value that signs a download URL 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. Nothing else. But an intermediate value doesn’t come back as a validation error:
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": 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=20260726T054741Z&X-Amz-Expires=900&X-Amz-SignedHeaders=host&X-Amz-Signature=41f5f16472...",
"expires_at": "2026-07-26T06:02:41.945820Z"
}
}
Look at what’s gone. No method, no headers, and X-Amz-SignedHeaders has collapsed to host — that’s a download signature. The word upload reads naturally, appears in at least one published flow example, and quietly falls through to the get branch. A client that PUTs to it gets SignatureDoesNotMatch every single time, and your API returned 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 | Not fixable from the API today — 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.
There is no route to set bucket CORS rules, so browser-direct upload straight to an Infrai bucket doesn’t work today. That’s a real limitation, not a configuration you’ve missed. If a web page must upload without touching your server, S3 with a CORSConfiguration or Cloudflare R2 with CORS rules on the bucket are the right choice and you should use them. Native apps, desktop helpers, CLI tools and your own backend are unaffected — CORS is a browser policy and nothing else enforces it.
Two things the signature isn’t doing for you
Strip the entire query string off a signed download URL and the object still comes back 200. The signature is an expiry mechanism and a routing convenience; it is not the access control. Treat unguessable keys, short TTLs and the authorisation check in your own minting route as the real protection.
And max_bytes is advisory here. We minted a ticket with max_bytes: 5, uploaded 40 bytes, and the backend stored all 40 — the cap is echoed in the response but isn’t inside the signature. Enforce size yourself before you hand out a ticket, 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} instead is $0.0002 per call, verified 2026-07-26; rates on this platform drift downward and discount campaigns run, so 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.