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 lives | Permanent public URL | Good for |
|---|---|---|
| Infrai bucket | No — set_acl only takes private | User content, exports, backups, anything tenant-scoped |
| Amazon S3 with a bucket policy | Yes | Public assets you’re already keeping in S3 |
| Cloudflare R2 with a custom domain | Yes, plus free egress | High-traffic public media |
| Supabase public bucket | Yes | Teams already on 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
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.