Avatars in an auth app: signed private URL or public CDN URL?

Profile pictures want a stable, cacheable address and signed links are neither. What Infrai storage will and won't do for avatars, and when a public CDN bucket wins.

If every signed-in user can already see everyone else’s face, avatars aren’t private data and a public CDN URL over a content-hashed key is the right answer. Signed URLs are the right answer only when the picture itself is restricted — a patient portal, a school roster, an internal directory that isn’t world-readable. Infrai storage does the second case well and refuses the first outright, which is unusual enough to say plainly before you design around it.

The refusal is not a configuration you’ve missed. Infrai buckets accept private and signed-only; ask for anything public and the API rejects the call rather than quietly ignoring it.

export INFRAI_API_KEY=your_infrai_api_key

curl -sS -X POST https://api.infrai.cc/v1/storage/bucket/create \
  -H "Authorization: Bearer ${INFRAI_API_KEY}" \
  -H "Content-Type: application/json" \
  -d '{"name":"kbg-pub-test-0726","acl":"public-read"}'
{
  "ok": false,
  "error": {
    "code": "STORAGE_ACL_INVALID",
    "http_status": 400,
    "message": "unsupported acl 'public-read'",
    "retryable": false
  }
}

So there is no permanent avatar URL to put in a <img src>, an email template, or an Open Graph tag. Every read is a fresh signature with its own expiry.

The cache arithmetic, which is what actually decides this

A public object has one address forever. Hash the bytes into the key — avatars/9f2c1a.webp — set a long Cache-Control, and the CDN edge serves it without touching your origin again. Change the picture, change the hash, change the row in your database. Cache invalidation stops being a problem because you never overwrite anything.

A signed URL cannot do that, and the reason is mechanical rather than philosophical: the signature is part of the query string, and a new signature is a new cache key. Mint the same avatar twice and you get two URLs that no cache will ever recognise as the same object.

Now scale it. A team directory showing 50 members needs 50 presigned links per page load, because there’s no batch presign route — POST /v1/storage/object/presign/{bucket}/{key} signs exactly one key per call. That’s 50 API round trips before the page renders, and again on the next render, with a CDN hit rate of zero. Presign calls are free, but they’re rate-limited, and 50 sequential calls at ~60ms each is three seconds of latency you didn’t have before. Cache the URLs in Redis for slightly less than their TTL and the problem shrinks; it doesn’t disappear.

ApproachStable URLCDN-cacheablePer-render costBest for
Public bucket + CDN (Cloudflare R2, S3 + CloudFront)yesyesnone after first hitAny avatar a logged-in user may see
Cloudinary or a similar image CDNyesyesnone, plus resizingTeams that also want on-the-fly crops
Infrai signed URL, minted per rendernonoone presign call per imageGenuinely restricted images
Your own proxy over GET /v1/storage/object/get/{bucket}/{key}yesyes, if you set the headersone billed read per missPer-request authorisation you must enforce

The header that catches people out

Even for the private case, an Infrai object URL is a download, not an image. Every object GET comes back with Content-Disposition: attachment and x-amz-force-download: true, and presign’s response_disposition field can rename the file but can’t turn that off:

URL=$(curl -sS -X POST \
  "https://api.infrai.cc/v1/storage/object/presign/kbg-avatarcdn-0726/avatars/u_8412/128.webp" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}" \
  -H "Content-Type: application/json" \
  -d '{"op":"get","expires_seconds":600,"response_disposition":"inline"}' \
  | python3 -c "import json,sys; print(json.load(sys.stdin)['data']['url'])")

curl -sS -o /dev/null -D - "$URL" | grep -iE 'content-disposition|force-download|cache-control'

You get Content-Disposition: attachment back regardless. Ask for attachment; filename="me.webp" instead and the filename does stick — so the field works, it just can’t make the response inline. Browsers generally ignore that header on an <img> subresource, but anything that navigates to the URL directly downloads a file. It’s a caveat, not a blocker, and you should know about it before a support ticket tells you.

Cache-Control behaves better than we expected: set it at write time and it survives to the response.

python3 -c "import base64,json,sys; print(json.dumps({'data_base64': base64.b64encode(open(sys.argv[1],'rb').read()).decode(), 'content_type': 'image/webp', 'cache_control': 'public, max-age=31536000, immutable'}))" avatar-128.webp > payload.json

curl -sS -X PUT \
  "https://api.infrai.cc/v1/storage/object/put/kbg-avatarcdn-0726/avatars/u_8412/128.webp" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}" \
  -H "Content-Type: application/json" \
  --data-binary @payload.json

That matters only for the proxy design, where your own handler is the cache origin. On a signed URL the directive is real but useless, since the key changes each time.

If the images really are private

Give yourself one stable route and let it redirect. Your app links to /avatar/u_8412, your handler authorises the viewer, mints a short link, and 302s:

import { createServer } from "node:http";

const API = "https://api.infrai.cc";
const BUCKET = "kbg-avatarcdn-0726";
const KEY = process.env.INFRAI_API_KEY;
if (!KEY) throw new Error("INFRAI_API_KEY is not set");

async function signAvatar(userId) {
  const res = await fetch(`${API}/v1/storage/object/presign/${BUCKET}/avatars/${userId}/128.webp`, {
    method: "POST",
    headers: { authorization: `Bearer ${KEY}`, "content-type": "application/json" },
    body: JSON.stringify({ op: "get", expires_seconds: 120 }),
  });
  if (!res.ok) throw new Error(`presign ${res.status}: ${await res.text()}`);
  const { data } = await res.json();
  return data.url;
}

createServer(async (req, res) => {
  const match = req.url?.match(/^\/avatar\/([a-z0-9_]+)$/i);
  if (!match) { res.writeHead(404).end(); return; }
  try {
    // Authorise the *viewer* here — the signature never will.
    const url = await signAvatar(match[1]);
    res.writeHead(302, { location: url, "cache-control": "private, max-age=60" });
    res.end();
  } catch (err) {
    console.error("avatar sign failed", err);
    res.writeHead(502).end();
  }
}).listen(8080);

Two minutes of TTL with a one-minute browser cache on the redirect is, in practice, a reasonable pairing: the redirect is cheap to repeat, and a leaked link dies fast.

Confirm the object is where you think it is before you debug anything else — head is free and tells you the stored content type, which is the usual culprit when an avatar renders as a broken image:

curl -sS "https://api.infrai.cc/v1/storage/object/head/kbg-avatarcdn-0726/avatars/u_8412/128.webp" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}"

What it costs, and the honest recommendation

Writes are $0.0001 per object/put and proxied reads $0.0002 per object/get; presign, head and list are free and rate-limited. New accounts get $2 free credit. Those were verified 2026-07-26, prices on this API trend downward, and you should read the current ones rather than this sentence:

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 public-facing profile picture, Cloudflare R2 with a custom domain, or Cloudinary if you also want server-side crops, will beat this on both latency and cost, and you’d be better off using them. Infrai earns its place when the avatar is one artefact among many on the same key — the queue that resizes it, the error group that catches a failed upload, the email that tells the user their photo was rejected. That consolidation is worth more than a per-call rate, and it’s the part a cheaper storage bill can’t replace.

References

Browse more storage developer guides