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’s storage layer makes this explicit: POST /v1/storage/object/set_acl/{bucket}/{key} accepts private 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 at the moment the page renders.

Ask the API for a public object and read the answer

This is the whole boundary in two calls. First, the request everyone tries:

export INFRAI_API_KEY="your_infrai_api_key"

curl -sS -X POST \
  "https://api.infrai.cc/v1/storage/object/set_acl/kb-profile-0726/avatars/usr_5540/original.png" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}" \
  -H "Content-Type: application/json" \
  -d '{"acl":"public-read"}'
{
  "ok": false,
  "error": {
    "code": "STORAGE_ACL_INVALID",
    "http_status": 400,
    "message": "unsupported acl 'public-read'",
    "retryable": false
  }
}

public-read-write, authenticated-read and bucket-owner-read fail the same way. The one value that succeeds tells you the rest:

{
  "ok": true,
  "data": { "acl": "private", "public_url": null }
}

That null is the API being honest. There’s no permanent address to hand out, which is a limitation if you were planning to drop a bucket URL into a stylesheet, and a feature if you’d rather not discover 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 right now looks fine.

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

What the expiry looks like from the client side

The browser shows a broken image; the network tab shows the truth:

<?xml version='1.0' encoding='utf-8' ?>
<Error>
  <Code>AccessDenied</Code>
  <Message>Request has expired</Message>
  <ServerTime>2026-07-26T00:36:44Z</ServerTime>
</Error>

AccessDenied with “Request has expired” means the signature was valid and is now too old. A plain 404 on a signed link means the key doesn’t exist — a different bug, usually a filename encoded once too often. And a 403 mentioning CORS on an image tag is neither; that’s the browser refusing a cross-origin fetch, not storage refusing you.

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-profile-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 will 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-profile-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..604800] seconds — which is a good reason not to try solving the email problem with a very long link.

When a genuinely public bucket is the right call

Some assets aren’t private at all: marketing images, a default avatar, an open-source 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 only takes privateUser content, exports, backups, anything tenant-scoped
Amazon S3 with a bucket policyYesPublic assets you’re already keeping in S3
Cloudflare R2 with a custom domainYes, plus free egressHigh-traffic public media
Supabase public bucketYesTeams already on 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

Presigning 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. The billable side is writes and API-mediated reads: verified 26 July 2026, storage.object.put is $0.0001 per call, storage.object.get $0.0002, plus stored bytes and egress. 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')) for c in json.load(sys.stdin)['capabilities'] if 'presign' in c['id'] or 'object' in c['id']]"

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: signatures are free, so “sign at render” is a design you can afford everywhere in your app — and the key that signs them also runs the email that embeds the avatar and the error tracker that tells you when the redirect fell over.

References

Browse more storage developer guides