Signed image URLs expiring in production: diagnosis and the fix
Why generated images stop loading hours after they rendered, how to tell an expired signature from a missing object, and the mint-on-render pattern that ends it.
Images render fine, then some hours later the same page is full of broken thumbnails. The object is still there. What expired is the signature you minted once and then stored — in a database column, a Redis cache, a server-rendered HTML page sitting behind a CDN, or a JSON blob the client hangs onto across a long session. Infrai’s presign route hands you a URL with an expires_at, and the storage host enforces it strictly, which is correct and also completely unforgiving of any design that treats the URL as durable.
The rule to internalise: a signed URL is a short-lived credential, not an address. Store the object key. Mint the URL at render time.
Tell the three failures apart in thirty seconds
The status code from the storage host answers this on its own.
| What you see | Status from the signed URL | Cause | Fix |
|---|---|---|---|
| Broke after working for a while | 403 | Signature past X-Amz-Expires | Mint on render; raise TTL to cover page lifetime |
| Broke immediately, every time | 403 | Query string truncated, re-encoded, or a proxy stripped a parameter | Pass the URL through untouched, don’t re-escape it |
| Broke immediately for one image | 404 | Signature is valid, the key isn’t there | Check head; the write probably failed |
| Broke for every user at once | 403 | You cached a rendered page longer than the TTL | Shorten CDN TTL below the signature TTL, or mint client-side |
We checked each of these against the live API: a signature past its expiry returns 403, a valid signature over a key that doesn’t exist returns 404, and a URL with a single character appended to the signature also returns 403. So 403-versus-404 cleanly separates “credential problem” from “object problem”.
export INFRAI_API_KEY="your_infrai_api_key"
SIGNED=$(curl -sS -X POST "https://api.infrai.cc/v1/storage/object/presign/kb-signed-links/gallery/u_907/sunset_5c2e.png" \
-H "Authorization: Bearer ${INFRAI_API_KEY}" \
-H "Content-Type: application/json" \
-d '{"op":"get","expires_seconds":300}' \
| python3 -c "import json,sys; print(json.load(sys.stdin)['data']['url'])")
curl -sS -o /dev/null -w "signed GET -> %{http_code}\n" "$SIGNED"
{
"ok": true,
"data": {
"url": "https://infrai-1333115350.cos.ap-singapore.myqcloud.com/a4ee0c441fa36c267.kb-signed-links/gallery/u_907/sunset_5c2e.png?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Date=20260726T003722Z&X-Amz-Expires=300&X-Amz-Signature=d8890c1b049a038a",
"expires_at": "2026-07-26T00:42:22.218024Z"
}
}
Is the object even there?
Before blaming the signature, ask the API. GET /v1/storage/object/head/{bucket}/{key} is free, needs no signature, and answers unambiguously.
curl -sS "https://api.infrai.cc/v1/storage/object/head/kb-signed-links/gallery/u_907/missing_0000.png" \
-H "Authorization: Bearer ${INFRAI_API_KEY}"
{
"ok": true,
"data": {
"found": false,
"status": "not_found",
"key": "gallery/u_907/missing_0000.png"
}
}
found: false and the whole signature conversation is irrelevant — your write path is broken, not your read path. That one call has saved more debugging hours than any amount of staring at query strings.
The server-side helper
Mint per request, but don’t mint per image on a gallery of sixty. A small cache keyed by object key, refreshed at 80% of the signature’s life, gets you both.
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 BUCKET = "kb-signed-links";
const TTL_SECONDS = 900;
const REFRESH_AT = 0.8;
const cache = new Map();
export async function signedUrlFor(objectKey) {
const hit = cache.get(objectKey);
if (hit && Date.now() < hit.refreshAfter) return hit.url;
const res = await fetch(`${API}/v1/storage/object/presign/${BUCKET}/${objectKey}`, {
method: "POST",
headers: { Authorization: `Bearer ${KEY}`, "Content-Type": "application/json" },
body: JSON.stringify({ op: "get", expires_seconds: TTL_SECONDS }),
});
const json = await res.json();
if (!res.ok || !json.ok) {
throw new Error(`presign ${objectKey}: HTTP ${res.status} ${JSON.stringify(json.error ?? json)}`);
}
const expiresAt = Date.parse(json.data.expires_at);
cache.set(objectKey, {
url: json.data.url,
refreshAfter: Date.now() + (expiresAt - Date.now()) * REFRESH_AT,
});
return json.data.url;
}
export async function signMany(keys) {
return Object.fromEntries(await Promise.all(keys.map(async (k) => [k, await signedUrlFor(k)])));
}
Two details matter more than they look. The cache is keyed by object key, never by user, because the URL isn’t user-specific — caching it per session multiplies your presign calls by your session count for no benefit. And the refresh threshold is a fraction of the actual remaining life read from expires_at, not a hard-coded number, so changing TTL_SECONDS can’t quietly desynchronise the two.
The client-side safety net
Even with a correct TTL, a browser tab left open over lunch will outlive any reasonable signature. Handle it where it shows up.
export function attachSignedImageRecovery(root) {
root.addEventListener("error", async (event) => {
const img = event.target;
if (!(img instanceof HTMLImageElement) || img.dataset.retried === "1") return;
img.dataset.retried = "1";
const objectKey = img.dataset.objectKey;
if (!objectKey) return;
const res = await fetch(`/api/media/sign?key=${encodeURIComponent(objectKey)}`);
if (!res.ok) return;
const { url } = await res.json();
img.src = url;
}, true);
}
The data-object-key attribute is the point: the DOM carries the durable identifier and the ephemeral URL is derived from it, which is the same discipline as the server cache one layer up. One retry, then give up — an infinite refresh loop against a deleted object is a self-inflicted denial of service.
Choosing a TTL
Short TTLs are safer and noisier. Long TTLs are quieter and leakier — a URL that lives for a week is a URL that lives in someone’s browser history, a support ticket screenshot and a Slack thread for a week.
Match the TTL to the page, not to the object. A server-rendered gallery cached at the edge for 5 minutes wants a signature comfortably longer than 5 minutes; 15 works. A single-page app that fetches URLs over XHR can use 60 seconds, because it can always ask again. For a downloadable export a user might open tomorrow, don’t reach for a longer signature at all — put an authenticated route in front of the object and redirect, so the credential is the user’s session and not a string in a URL.
Presigning is free and rate-limited, so nothing about this costs you anything: POST /v1/storage/object/presign/{bucket}/{key} and GET /v1/storage/object/head/{bucket}/{key} are both free, and only the byte-moving routes are metered — writes at $0.0001 per call and JSON reads at $0.0002, verified 26 July 2026. Confirm today’s numbers rather than trusting the page:
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')) for c in json.load(sys.stdin)['capabilities'] if 'storage.object' in c['id']]"
Rates trend downward and discount campaigns run, so what you read is at least as likely to be lower than what’s printed here.
What the signature does and doesn’t buy you
Expiry. That’s the honest scope of it on Infrai today.
Don’t build your authorisation model on it. A presigned URL exposes the object’s full storage path, including the account prefix, so anyone who has seen one link has learned the shape of your namespace. Name objects so that knowing the shape doesn’t help — a random 16-byte id or a content hash, never gallery/user_907/latest.png — keep expiry short for anything sensitive, and put the actual permission check in the API route that decides whether to mint the link. That route is where “does this user own this image” belongs.
Amazon S3 has the same property and additionally caps SigV4 URLs at 7 days, which is one reason its documentation pushes you toward CloudFront signed cookies for long-lived access. Cloudflare R2 behaves the same way through its S3 API. So this isn’t an Infrai quirk — it’s what presigned URLs are, everywhere.
Limits worth flagging
Infrai doesn’t support setting bucket CORS rules, so a browser can’t PUT into the bucket; the recovery pattern above works because reads are plain image loads rather than XHR. If you need genuinely long-lived public image URLs on a CDN you control, R2 with a custom domain is the better pick, and it’s not close.