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 any of that. On an Infrai bucket the five things that actually produce it are an expired window, a mutated query string, a method the signature didn’t cover, an upload whose signed headers weren’t echoed, and a link built by hand instead of minted. Each one has 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

SymptomStatusCauseFix
<Message>Request has expired</Message>403expires_seconds elapsedMint on click, not on page render
SignatureDoesNotMatch after a redirect or a logger touched the URL403Query string mutatedPass the URL opaquely, never re-encode it
curl -I works nowhere but curl works403Signature is method-boundSign get, then issue GET
Upload rejected instantly, download of the same key fine403Signed headers not echoedSend exactly the headers map returned by presign
Correct-looking link for a key you never wrote404Signature valid, object absentGET /v1/storage/object/head/{bucket}/{key} first

The last row is the useful one. A valid signature over a key that doesn’t exist gives 404, not 403 — so a 403 tells you the problem is the link, and a 404 tells you the problem is the object. 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.

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.

The thing a signature is not

Strip the query string off a working download URL and the object still comes back 200. In our testing, setting the object to signed-only made no difference either. So the signature is an expiry and a convenience, and the actual protections are unguessable server-derived keys, a short TTL, and authorisation checks in the route that mints the link — not the signature itself. If you need enforced per-request authorisation on the bytes, proxy them through your own handler with GET /v1/storage/object/get/{bucket}/{key} and accept the egress cost.

Presign, head and list calls are free and rate-limited; a proxied object/get is $0.0002 per call, verified 2026-07-26, and rates on this API move down over time rather than up. 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 same key already covers the queue that generated the export, the error capture when the minter throws, and the invoice that tells you what all of it cost.

References

Browse more storage developer guides