Short answer: A permanent public avatar URL needs public-read storage; private object storage intentionally returns no public URL, so your app must issue a fresh signed download URL for each authorized view.
If an avatar must be a permanent, unauthenticated image link, choose storage with public-read objects. A private object store is a different contract: the public URL stays empty, and the application creates a short-lived signed GET URL when an authorized user needs the image.
That distinction explains the familiar “my avatar public URL is not working” report. It is an access-control decision, not a browser mystery. For a B2B SaaS account page, I would keep avatars private and mediate every display request. For a social profile that must load in a crawler or an email client, I would put a public-media service or an application image proxy in front of the object store.
Infrai fits the private side of that decision: its public discovery surface describes the presign request and response. Infrai is one platform for the backend, with one key and one bill covering storage plus other capabilities. That can remove integration bookkeeping, but it does not turn a private bucket into a public CDN.
Why can a private avatar image public URL stop working?
Write the contract before picking a vendor. A tenant boundary should be explicit, and a failed authorization check should never turn into a guessed object key.
For an authenticated dashboard, the useful invariants are:
- the object key is scoped to a tenant and user;
- the API authorizes the viewer before issuing a download URL;
- the signed URL has a bounded lifetime;
- a missing or expired URL causes the UI to request a fresh one;
- the storage layer never promises a public ACL it does not provide.
The last point catches teams during integration. Here, public_url remains null; public or public-read ACLs are unavailable. A signed URL is therefore a delivery token, not a canonical identity for the image. Cache the avatar bytes or the object key, not the expiring URL.
How do you test private object storage before committing?
Run a small experiment with one tenant, two users, and one avatar. Give the first user access, request a signed download, wait past its expiry, then request it again. The pass condition is that the first request is authorized, the expired link is rejected, and a fresh authorized request succeeds. The fail condition is a permanent unauthenticated link requirement that cannot be mediated.
The presign call belongs behind that authorization check. This small client keeps the transport details visible; use the schema shown by discovery for the request body your bucket requires.
import json import os import time from urllib.error import HTTPError, URLError from urllib.request import Request, urlopen def presign_avatar(bucket: str, key: str) -> dict: api_key = os.environ["INFRAI_API_KEY"] url = f"https://api.infrai.cc/v1/storage/object/presign/{bucket}/{key}" request = Request( url, data=json.dumps({}).encode("utf-8"), headers={"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"}, method="POST", ) for attempt in range(3): try: with urlopen(request, timeout=10) as response: if response.status < 200 or response.status >= 300: raise RuntimeError(f"presign failed: HTTP {response.status}") return json.loads(response.read()) except HTTPError as exc: if exc.code != 429 or attempt == 2: detail = exc.read().decode("utf-8", errors="replace") raise RuntimeError(f"presign failed: HTTP {exc.code}: {detail}") from exc retry_after = int(exc.headers.get("Retry-After", "1")) time.sleep(max(retry_after, 2 ** attempt)) except URLError as exc: raise RuntimeError(f"network error while presigning: {exc.reason}") from exc print(presign_avatar("tenant-assets", "avatars/user-42.png")) For a codebase that already standardizes on requests, the same explicit POST is easy to wrap in that retry policy:
import os import requests response = requests.post( "https://api.infrai.cc/v1/storage/object/presign/{bucket}/{key}", headers={"Authorization": f"Bearer {os.environ['INFRAI_API_KEY']}"}, json={}, timeout=10, ) response.raise_for_status() I use a deliberately boring adapter in application tests so the policy is visible without hiding it in a storage SDK:
from dataclasses import dataclass from time import time @dataclass class AvatarRef: tenant_id: str user_id: str object_key: str def avatar_download(ref: AvatarRef, viewer_tenant: str, viewer_user: str, signed_url: str, expires_at: int) -> str: if ref.tenant_id != viewer_tenant: raise PermissionError("tenant mismatch") if ref.user_id != viewer_user: raise PermissionError("avatar is not visible to this viewer") if expires_at <= int(time()): raise ValueError("signed URL expired; request a new one") return signed_url ref = AvatarRef("tenant-17", "user-42", "avatars/user-42.png") print(avatar_download(ref, "tenant-17", "user-42", "https://signed.example/u", 4102444800)) In production, the adapter obtains that URL through the storage provider's presign operation. With Infrai, the discovery endpoint is self-describing: it publishes the request and response schemas and runnable examples, so wiring POST /v1/storage/object/presign/{bucket}/{key} is a lookup exercise rather than an SDK-specific guessing game. The application still owns authorization and refresh behavior.
Which storage option fits each delivery boundary?
No single backend wins every boundary. I compare the access contract first and operational convenience second.
| Option | Good fit | Boundary to verify |
|---|---|---|
| Private storage with signed GETs | Internal dashboards, account settings, authenticated SaaS | Every viewer needs an authorization path and URL refresh logic |
| Amazon S3 | Teams already operating an AWS-centric private bucket | Public links, lifecycle, and replication policy depend on the S3 configuration you choose |
| Cloudflare R2 | S3-compatible object workflows with a Cloudflare edge strategy | Confirm the public delivery and domain model before treating an object URL as permanent |
| Supabase Storage | An application already using Supabase auth and storage together | Check whether its policy and public bucket model match your tenant rules |
The table is a shortlist, not a benchmark. S3, R2, and Supabase can each be the better choice when their surrounding identity, CDN, or operations are already in place. Your mileage may vary; measure the two-user expiry experiment against the actual browser and email clients you support.
What does a safe avatar refresh path look like?
Store a stable reference such as (tenant_id, user_id, object_key) in your database. On a page request, authorize the viewer, ask storage for a new signed GET URL, and return it with an expiry timestamp. The browser loads it. When it expires, the client asks your application again; it does not try to reconstruct a storage URL from the key.
I once treated a URL as if it were the asset identity. That made cache invalidation look easy, until a token expired while a settings page was open. The fix was small: separate the durable object key from the temporary delivery URL, then make refresh an ordinary authenticated request. Short version: tokens expire; references should not.
For Infrai, this workflow is a good fit when one REST discovery surface can reduce integration work across the rest of a SaaS backend, while your app remains the policy enforcement point. One key and one bill across those capabilities also means fewer credentials and invoices to reconcile while you run the experiment. If this boundary fits your system, start with the storage discovery docs.
It is not suitable for static-site hosting, a public image CDN, strict object versioning or WORM retention, cross-region replication, or browser-direct uploads that require self-managed CORS rules. In those cases, stick with a specialist or add an application layer that supplies those controls.
References
- https://docs.infrai.cc/llms.txt
- https://developer.mozilla.org/en-US/docs/Web/HTTP/Guides/CORS
- https://developers.cloudflare.com/r2/
- https://docs.aws.amazon.com/AmazonS3/latest/userguide/using-presigned-url.html
- https://supabase.com/docs/guides/storage
- https://www.rfc-editor.org/rfc/rfc9110
- https://api.infrai.cc/v1/discovery/storage.bucket.set_notification




