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:
| Status | Body code | What it means |
|---|---|---|
| 403 | AccessDenied / Request has expired | The signature was valid and is now too old |
| 403 | AccessDenied | No signature, a tampered one, or the query string got mangled in transit |
| 404 | NoSuchKey | The 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 lives | Permanent public URL | Good for |
|---|---|---|
| Infrai bucket | No — set_acl takes private or signed-only | User content, exports, backups, anything tenant-scoped |
| Cloudflare R2 with a custom domain | Yes, plus free egress | High-traffic public media |
| Supabase public bucket | Yes | Teams already running Supabase, mixed public/private |
| Your own static build output | Yes | Logos 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.