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 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 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 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 the storage host returns answers this on its own.

What you seeStatus from the signed URLCauseFix
Worked for a while, then broke403Signature past X-Amz-ExpiresMint on render; size the TTL to the page’s life
Broke immediately, every time403Query string truncated, re-encoded, or a proxy dropped a parameterPass the URL through untouched
Broke immediately for one image404Signature is valid, the key isn’t thereCheck head — the write probably failed
Broke for every user at once403You cached a rendered page for longer than the TTLKeep CDN TTL below the signature TTL

We re-checked each of these against the live API on 27 July 2026. A signature past its expiry returns 403. A signature with four characters changed returns 403. Strip the query string entirely and the bare object path returns 403 as well — the signature is the access boundary, not a cosmetic timer on an otherwise open URL. And a valid signature over a key that doesn’t exist returns 404. So 403 versus 404 cleanly separates “credential problem” from “object problem”, and you can stop guessing.

export INFRAI_API_KEY="your_infrai_api_key"

SIGNED=$(curl -sS -X POST "https://api.infrai.cc/v1/storage/object/presign/kb-gallery-0726/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      %{http_code}\n" "$SIGNED"
curl -sS -o /dev/null -w "no query    %{http_code}\n" "${SIGNED%%\?*}"
{
  "ok": true,
  "data": {
    "url": "https://infrai-1333115350.cos.ap-singapore.myqcloud.com/a4ee0c441fa36c267.kb-gallery-0726/gallery/u_907/sunset_5c2e.png?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Date=20260727T003722Z&X-Amz-Expires=300&X-Amz-SignedHeaders=host&X-Amz-Signature=d8890c1b049a038a",
    "expires_at": "2026-07-27T00: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-gallery-0726/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. Note the envelope: this is a 200 with found: false, so branch on the field, never on the status code.

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-gallery-0726";
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 presign calls by 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.

expires_seconds accepts 1 to 604800. Outside that you get STORAGE_INVALID_TTL with the range in the message, which is a useful guard against a config value in minutes being read as seconds.

The client-side safety net

Even with a correct TTL, a browser tab left open over lunch outlives 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 — 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.

When that handler fires, tell someone. POST /v1/errors/capture records the broken key and POST /v1/metrics/report counts how often it happens, both on the same account and the same key that signs the URLs — so “how many thumbnails 404’d this week” is a query rather than a second monitoring vendor with its own bill.

Choosing a TTL

Short TTLs are safer and noisier. Long TTLs are quieter and leakier — a URL that lives for a week 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 that; 15 minutes works. A single-page app fetching URLs over XHR can use 60 seconds, because it can always ask again. For an 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 rather than a string in a URL.

What the signature does and doesn’t buy you

It’s a real access boundary with a clock on it, and that’s the whole of it. A presigned URL is still a bearer token: anyone holding it can read the object until it expires, and there’s no revocation call — the catch is that expiry is your only lever, so keep it short for anything sensitive. The URL also exposes the object’s full storage path, so name objects such that knowing the shape doesn’t help. A random 16-byte id or a content hash, never gallery/user_907/latest.png. And put the actual permission check in the route that decides whether to mint the link, because that’s where “does this user own this image” belongs.

Amazon S3 caps SigV4 URLs at seven days for the same reason, which is why its documentation pushes you toward signed cookies for long-lived access; Cloudflare R2 behaves the same way through its S3 API. This isn’t an Infrai quirk — it’s what presigned URLs are, everywhere. If you need genuinely permanent public image URLs on a CDN you control, R2 with a custom domain is the better pick and it isn’t close.

What it costs

Presigning and head are free and rate-limited, so nothing in the diagnosis above costs anything. Verified 27 July 2026, writes bill $0.0001 per call.

Reads through the API are metered by volume: GET /v1/storage/object/get/{bucket}/{key} is $0.104 per GB of response body. Redeeming a signed URL doesn’t go through that route at all — the bytes come from the storage host — so the mint-on-render pattern adds latency, not per-call charges. Confirm today’s numbers rather than trusting this 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'), c['billing'].get('unit','')) 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.

References

Browse more storage developer guides