Why your avatar URL stops working: private buckets have no public link

A private object has no permanent address. Why the URL in your database expired, how to sign at render time, and the redirect endpoint that fixes emails.

Because there is no public URL to have. On a private bucket every read is either an authenticated API call or a link carrying a signature and an expiry, and the address you saved in your users.avatar_url column was the second kind — it worked for an hour, or a day, and then the signature aged out. Infrai makes that explicit: POST /v1/storage/object/set_acl/{bucket}/{key} accepts private and signed-only and returns public_url: null, and any other value comes back as STORAGE_ACL_INVALID.

So the fix isn’t a longer expiry. It’s to stop persisting URLs at all — store the object key, and mint a fresh link when the page renders.

Ask the API for a public object and read the answer

Two calls settle the whole question. Set your key first:

export INFRAI_API_KEY="your_infrai_api_key"

Now the request everyone tries — asking for a public object ACL, which the storage layer refuses by design:

POST /v1/storage/object/set_acl/kb-avatars-0726/avatars/usr_5540/original.png HTTP/1.1
Host: api.infrai.cc
Authorization: Bearer $INFRAI_API_KEY
Content-Type: application/json

{"acl":"public-read"}
{
  "ok": false,
  "error": {
    "code": "STORAGE_ACL_INVALID",
    "http_status": 400,
    "message": "unsupported acl 'public-read'",
    "retryable": false,
    "hint": "ACL not in storage_acl enum (private/signed-only; public-read refused)."
  }
}

public-read-write, authenticated-read and bucket-owner-read fail the same way. The values that succeed tell you the rest:

{
  "ok": true,
  "data": { "acl": "signed-only", "public_url": null }
}

That null isn’t a placeholder waiting to be filled in. Strip the query string off a working signed link and fetch the bare object path and you get HTTP 403 with AccessDenied — the signature is what grants the read, not decoration on top of an otherwise open object. Which is a limitation if you were planning to drop a bucket URL into a stylesheet, and a feature if you’d rather not find a public object listing on a Monday morning.

The bug is nearly always a stored URL

Look for a schema like this, because it’s where the failure lives:

-- The problem: a signed, expiring string persisted as if it were an address.
ALTER TABLE users ADD COLUMN avatar_key text;

UPDATE users
   SET avatar_key = regexp_replace(
         split_part(avatar_url, '?', 1),
         '^https://[^/]+/[^/]+/', ''
       )
 WHERE avatar_url IS NOT NULL;

ALTER TABLE users DROP COLUMN avatar_url;

A key like avatars/usr_5540/original.png is stable, greppable and small. A signed URL is 500-odd characters of query string with a timestamp buried in it, and the moment you cache it in Redis, embed it in a JWT claim or serialise it into a client-side store, you’ve created a value that will be wrong later and looks fine right now.

Worth flagging: the same mistake in reverse breaks CDN caching. Every fresh signature is a new URL, so a page that re-signs on every render also re-downloads every image.

Read the status code, not the pixels

The browser shows a broken image. The network tab shows which of three things happened, and they’re cleanly separated:

StatusBody codeWhat it means
403AccessDenied / Request has expiredThe signature was valid and is now too old
403AccessDeniedNo signature, a tampered one, or the query string got mangled in transit
404NoSuchKeyThe signature is fine; the object isn’t there — check your write path

A HEAD request against a URL signed for GET is also a 403, which surprises people who add a preflight existence check in front of an image load. Use GET /v1/storage/object/head/{bucket}/{key} on the API instead; it’s free and it answers with found: true|false inside a 200.

Sign at render, behind a stable URL of your own

Here’s the pattern that survives contact with emails, mobile clients and template caches: keep one permanent URL on your own domain, and have it redirect to a freshly signed link.

import express from "express";

const API = "https://api.infrai.cc";
const BUCKET = "kb-avatars-0726";
const TTL_SECONDS = 600;

const token = process.env.INFRAI_API_KEY;
if (!token) throw new Error("INFRAI_API_KEY is not set");
const app = express();

app.get("/u/:userId/avatar.png", async (req, res) => {
  const key = `avatars/${req.params.userId}/original.png`;
  try {
    const head = await fetch(`${API}/v1/storage/object/head/${BUCKET}/${key}`, {
      method: "GET",
      headers: { Authorization: `Bearer ${token}` },
    });
    const meta = await head.json();
    if (!meta.data?.found) return res.redirect(302, "/static/avatar-fallback.png");

    const signed = await fetch(`${API}/v1/storage/object/presign/${BUCKET}/${key}`, {
      method: "POST",
      headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
      body: JSON.stringify({ op: "get", expires_seconds: TTL_SECONDS }),
    });
    const slot = await signed.json();
    if (!signed.ok || slot.ok === false) throw new Error(slot?.error?.code ?? `HTTP ${signed.status}`);

    res.set("Cache-Control", `public, max-age=${Math.floor(TTL_SECONDS / 2)}`);
    res.redirect(302, slot.data.url);
  } catch (err) {
    console.error("avatar redirect failed", err);
    res.redirect(302, "/static/avatar-fallback.png");
  }
});

app.listen(3000);

The redirect is what makes it work everywhere. https://yourapp.com/u/usr_5540/avatar.png never expires, so it’s safe in an email, a CSV export or a partner’s HTML, while the thing it points at lives for ten minutes. Set max-age to half the TTL and intermediate caches can never hold a link past its death.

You can mint the same link by hand while debugging:

curl -sS -X POST \
  "https://api.infrai.cc/v1/storage/object/presign/kb-avatars-0726/avatars/usr_5540/original.png" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}" \
  -H "Content-Type: application/json" \
  -d '{"op":"get","expires_seconds":600}'

Ask for more than seven days and you get STORAGE_INVALID_TTL — the accepted range is 1 to 604800 seconds — which is a good reason not to try solving the email problem with a very long link. Solve it with the redirect instead.

And the email that embeds that avatar is POST /v1/email/send on one credential with the bucket, so “store the image, sign the link, send the welcome mail” is one account, one key and one usage view rather than a storage vendor plus a mail vendor plus the glue between them.

When a genuinely public object is the right call

Some assets aren’t private at all: marketing images, a default avatar, a project logo. Signing those is pure overhead, and this API can’t make them public.

Where the asset livesPermanent public URLGood for
Infrai bucketNo — set_acl takes private or signed-onlyUser content, exports, backups, anything tenant-scoped
Cloudflare R2 with a custom domainYes, plus free egressHigh-traffic public media
Supabase public bucketYesTeams already running Supabase, mixed public/private
Your own static build outputYesLogos and defaults — no bucket needed at all

If most of your images are public, you’d be better off putting them on a static host or R2 and keeping only the private ones here. Mixing the two is fine; pretending a private object has an address is not.

What the signing loop costs

Presign and head are free and rate-limited, so a redirect endpoint that signs on every request adds no per-call charge — only latency, roughly 60–80 ms per pair of calls in our testing. Verified 27 July 2026, the metered routes are writes at $0.0001 per call and API-mediated reads at $0.104 per GB of response body, plus stored bytes. New accounts carry $2 of credit.

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 c['id'].startswith('storage.object')]"

These rates trend downward, so read the live number instead of quoting this page back at yourself in six months. What won’t change is the shape: signing is free, so “sign at render” is a design you can afford everywhere in your app, and redeeming the link never touches the API at all.

References

Browse more storage developer guides