Presigned URL returns 403: separating the five causes in one pass
A 403 on a signed object-storage link has five distinct causes and each leaves a different fingerprint. The probes that identify yours, in curl and Node 22.
A 403 from a presigned URL is never “the file is missing” and never “the user isn’t allowed” — the signature layer answers before either question gets asked. That’s the property that makes triage fast on an Infrai bucket: the signature is checked first and enforced, so a 403 is always a statement about the link and a 404 is always a statement about the object. Five things produce the 403 in practice — an expired window, a mutated or dropped query string, a method the signature didn’t cover, an upload whose signed headers weren’t echoed, and a link assembled by hand instead of minted — and each leaves a fingerprint you can read in about thirty seconds.
Start by reading the response body rather than the status line. Infrai’s POST /v1/storage/object/presign/{bucket}/{key} hands back a vendor URL, so the failure comes back as the storage vendor’s XML, and that XML names the cause outright.
export INFRAI_API_KEY=your_infrai_api_key
URL=$(curl -sS -X POST \
"https://api.infrai.cc/v1/storage/object/presign/kbg-sig403-0726/exports/tenant_42/invoice-2026-07.csv" \
-H "Authorization: Bearer ${INFRAI_API_KEY}" \
-H "Content-Type: application/json" \
-d '{"op":"get","expires_seconds":600}' | python3 -c "import json,sys; print(json.load(sys.stdin)['data']['url'])")
curl -sS "$URL" -o /dev/null -w "%{http_code}\n"
{
"ok": true,
"data": {
"url": "https://infrai-1333115350.cos.ap-singapore.myqcloud.com/a4ee0c441fa36c267.kbg-sig403-0726/exports/tenant_42/invoice-2026-07.csv?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=...&X-Amz-Date=20260726T050601Z&X-Amz-Expires=600&X-Amz-SignedHeaders=host&X-Amz-Signature=bc7217d0...",
"expires_at": "2026-07-26T05:16:01.795920Z"
}
}
Note X-Amz-SignedHeaders=host on a download link. That single field is the difference between the download case and the upload case, and it’s where most upload 403s come from.
The fingerprints
| Symptom | Status | Cause | Fix |
|---|---|---|---|
<Message>Request has expired</Message> plus a <ServerTime> element | 403 | expires_seconds elapsed | Mint on click, not on page render |
Bare Access Denied. on a path with no ? after it | 403 | Query string dropped by a proxy, rewrite rule or link shortener | Pass the whole URL through untouched |
SignatureDoesNotMatch after a redirect or a logger touched the URL | 403 | Query string mutated | Pass the URL opaquely, never re-encode it |
curl -I works nowhere but curl works | 403 | Signature is method-bound | Sign get, then issue GET |
| Upload rejected instantly, download of the same key fine | 403 | Signed headers not echoed | Send exactly the headers map returned by presign |
| Correct-looking link for a key you never wrote | 404 | Signature valid, object absent | GET /v1/storage/object/head/{bucket}/{key} first |
The last row is the useful one, and it only works because of the row above it. A valid signature over a key that doesn’t exist gives 404; an invalid or absent signature gives 403 whether or not the object is there. So the status code partitions the problem for you — 403 means fix the link, 404 means fix the object — and that split saves most of the debugging.
Expiry, and the clock question
SHORT=$(curl -sS -X POST \
"https://api.infrai.cc/v1/storage/object/presign/kbg-sig403-0726/exports/tenant_42/invoice-2026-07.csv" \
-H "Authorization: Bearer ${INFRAI_API_KEY}" \
-H "Content-Type: application/json" \
-d '{"op":"get","expires_seconds":1}' | python3 -c "import json,sys; print(json.load(sys.stdin)['data']['url'])")
sleep 3
curl -sS "$SHORT"
<?xml version='1.0' encoding='utf-8' ?>
<Error>
<Code>AccessDenied</Code>
<Message>Request has expired</Message>
<ServerTime>2026-07-26T05:06:55Z</ServerTime>
<Resource>/a4ee0c441fa36c267.kbg-sig403-0726/exports/tenant_42/invoice-2026-07.csv</Resource>
<RequestId>NmE2NTk1ZWZfYTI0OTBhMWRfMTVkN2VfYWNiYThkZg==</RequestId>
</Error>
ServerTime is the authority. Compare it with the expires_at you stored when you minted the link and you know instantly whether the window really closed or whether something else is wrong.
Clock skew deserves a caveat, because the advice you’ll find for S3 mostly doesn’t transfer. When you sign locally with the AWS SDK, your machine’s clock goes into X-Amz-Date, so a host running eight minutes fast can produce links that are already dead. Infrai signs on the server, so the timestamp is the API’s, not yours — skew on your app server can’t invalidate the link. What it can do is make your own expires_at arithmetic wrong, which shows up as a UI that says “valid for 10 more minutes” over a link that already 403s.
The two failures that look like a bug in your code
Method binding first. A signed GET URL is signed for GET, so a HEAD probe against it — which is exactly what a health check or a link-checker sends — comes back 403 even though the link is perfectly good.
curl -sS -o /dev/null -w "GET %{http_code}\n" "$URL"
curl -sS -o /dev/null -w "HEAD %{http_code}\n" -I "$URL"
That prints 200 and 403. If your monitoring says the export links are broken and your users say they aren’t, this is why.
The upload side is the other one. Ask for op: "put" and the response carries a headers object, and X-Amz-SignedHeaders grows to content-type;host. The content type is inside the signature, so the client must send that exact header back:
PUT_URL=$(curl -sS -X POST \
"https://api.infrai.cc/v1/storage/object/presign/kbg-sig403-0726/uploads/probe.txt" \
-H "Authorization: Bearer ${INFRAI_API_KEY}" \
-H "Content-Type: application/json" \
-d '{"op":"put","expires_seconds":300,"content_type":"text/plain"}' | python3 -c "import json,sys; print(json.load(sys.stdin)['data']['url'])")
curl -sS -o /dev/null -w "no header %{http_code}\n" -X PUT --data 'hello' "$PUT_URL"
curl -sS -o /dev/null -w "wrong type %{http_code}\n" -X PUT -H 'Content-Type: application/json' --data 'hello' "$PUT_URL"
curl -sS -o /dev/null -w "exact match %{http_code}\n" -X PUT -H 'Content-Type: text/plain' --data 'hello' "$PUT_URL"
403, 403, 200. Browsers and HTTP clients love to guess a content type for you — axios will happily label a Blob as application/octet-stream — and that guess breaks the signature. Echo the map you were given, don’t construct one.
Check the object before you blame the link
curl -sS "https://api.infrai.cc/v1/storage/object/head/kbg-sig403-0726/exports/tenant_42/invoice-2026-07.csv" \
-H "Authorization: Bearer ${INFRAI_API_KEY}"
Head is free and returns {"found": true, "size_bytes": 14, "etag": "...", "content_type": "text/csv"}. A missing key answers found: false with HTTP 200 rather than 404, so branch on the field. Presign itself does no existence check at all — it will cheerfully return 200 and a URL for a key that was never written, and the 404 only shows up when someone clicks.
Here’s the minter that folds all of it together:
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 downloadLink(bucket, key, ttlSeconds = 300) {
const head = await fetch(`${API}/v1/storage/object/head/${bucket}/${key}`, { headers: auth });
if (!head.ok) throw new Error(`head failed: ${head.status} ${await head.text()}`);
const meta = await head.json();
if (!meta?.data?.found) return { status: "gone" };
const res = await fetch(`${API}/v1/storage/object/presign/${bucket}/${key}`, {
method: "POST",
headers: auth,
body: JSON.stringify({ op: "get", expires_seconds: ttlSeconds }),
});
if (!res.ok) throw new Error(`presign failed: ${res.status} ${await res.text()}`);
const { data } = await res.json();
return { status: "ok", url: data.url, expiresAt: data.expires_at, sizeBytes: meta.data.size_bytes };
}
Two rules make 403s mostly disappear: mint at the moment of the click with a short TTL, and never store a signed URL in a database or an email. A link that lives in a row will outlive its signature — that’s not a failure, that’s the design working.
Why the 403 is worth trusting
Take the same working URL and cut everything from the ? onwards. The bytes don’t come back:
curl -sS "${URL%%\?*}"
<?xml version='1.0' encoding='utf-8' ?>
<Error>
<Code>AccessDenied</Code>
<Message>Access Denied.</Message>
<Resource>/a4ee0c441fa36c267.kbg-sig403-0726/exports/tenant_42/invoice-2026-07.csv</Resource>
<RequestId>NmE2NzU2NmZfZmE4NjIxY18yNDAyYl9jMmI3OTEz</RequestId>
</Error>
That single behaviour is what makes the whole table above diagnostic. The signature is the access boundary, so an unsigned request never reaches the object lookup, and the two status codes stay cleanly separated. Were a bare object path readable, 403 and 404 would blur into “sometimes 200” and every row in that table would become a guess.
It also means the URL is a bearer token, and the hygiene follows from that rather than from paranoia. Keep TTLs short — the accepted range is 1 to 604800 seconds, and a download button rarely needs more than a few hundred. Derive object keys on the server so they can’t be enumerated. Put the authorisation check in the route that mints the link, because once the link exists it works for whoever holds it. A signed URL pasted into a group chat is a valid credential until it expires.
If you need per-request authorisation on every byte instead — revocable mid-download, checked against your own session — proxy through your own handler with GET /v1/storage/object/get/{bucket}/{key} and pay for the relay.
Presign, head and list calls are free and rate-limited. The proxy route is the one that meters: GET /v1/storage/object/get/{bucket}/{key} bills $0.104 per GB of egress, verified 2026-07-27, so relaying is priced on the bytes you move rather than on how many links you mint. Rates on this API move down over time rather than up, so read today’s numbers instead of trusting the line above:
curl -sS "https://api.infrai.cc/v1/discovery" \
-H "Authorization: Bearer ${INFRAI_API_KEY}" | \
python3 -c "import json,sys; d=json.load(sys.stdin); print([(c['id'], c['billing'].get('price_usd')) for c in d['capabilities'] if c['id'].startswith('storage.object')])"
For a team that already lives in AWS, S3 presigning through the SDK gives you condition keys, bucket policies and signature v4 debugging tools that Infrai doesn’t support, and that’s the better pick when signature semantics are your daily work. Cloudflare R2 is the better pick when the same object also needs to sit behind a CDN. The reason to sign here instead is that the rest of the export pipeline is already on the same account — POST /v1/queue/publish for the job that generated the CSV, POST /v1/errors/capture for the night the minter throws, and one usage view that prices all of it per tenant. No second account, no second key to rotate.