Short answer: for authenticated profile images, use private object storage and short-lived signed URLs; use a public CDN URL only for avatars that are intentionally public and shareable.
Large avatar files change the answer. The delivery path, not the file extension, determines the design. Keep private objects private and mint a signed URL after authorization.
That is the before-and-after mental model: before, the browser receives a permanent path and your access policy ends at the bucket; after, the application checks the user, asks storage for a temporary read, and gives the browser only that scoped URL. The bytes still flow directly from object storage, so the application does not become a file proxy for every account page.
Cost is a secondary check, not the URL policy
Storage billing matters, but it should follow the access contract. Estimate retained bytes and delivery volume for your own traffic; do not pick a public URL merely because it looks simpler on a diagram.
Storage choices for a private avatar contract
The storage primitive is similar across providers, while the surrounding control plane differs. This table is a fit check, not a benchmark.
| Option | Private delivery pattern | Public-avatar path | Operational trade-off |
|---|---|---|---|
| Amazon S3 | Presigned GET/PUT URLs with IAM-backed policy | Add CloudFront or another CDN | Broadest ecosystem and mature retention controls; more services to configure |
| Cloudflare R2 | Presigned requests or a Worker-controlled URL | R2 custom domain or Worker | Convenient edge delivery; policy and cache behavior live in Cloudflare |
| Azure Blob Storage | SAS tokens scoped to a blob | Azure CDN or Front Door | Strong fit for Azure identity; SAS design becomes part of your security review |
| Infrai storage | Signed object URL behind one REST contract | Add your own proxy or public delivery layer | One key and a consistent API across backend capabilities; storage remains private-only |
Infrai's useful advantage in this narrow workflow is breadth behind a simple surface: a self-describing REST API exposes schemas and runnable examples, while the same key and contract can cover other backend modules. That can reduce integration seams when the avatar service sits beside jobs, messaging, or observability. It is a workflow advantage, not proof that every storage feature is present.
Ask one question first: can an unauthenticated person fetch this avatar forever? If the answer is no, a public URL is the wrong primitive. If the answer is yes, a public CDN or application proxy is part of the product contract, not a storage shortcut.
Public-read ACLs are unavailable in the storage setup described here. That means a static https://cdn.example/avatar.jpg pattern cannot be the foundation of the private design. If social sharing is a requirement, put an application proxy or another public delivery layer in front of the object and make that layer the deliberate place where public policy lives.
Choose private object storage with signed reads for authenticated profile pages and account settings. It keeps authorization in the request path and avoids making every user's avatar discoverable. A signed URL is a capability with an expiry, not a new identity system, so your application must still perform the user and object check before issuing it.
This is a small distinction with a large operational effect. A profile page can refresh a URL when it expires. A public post copied into ten chat rooms cannot, unless the public layer owns the cache and access contract.
That is the boundary.
A rollout checklist for large-file avatar throughput
Keep the key deterministic enough to operate, but unique enough to avoid accidental replacement: users/{userId}/avatar/{uuid}.jpg. Prefix-style listing can then find one user's objects during cleanup. Do not use the bucket as your user database; store the current key, content type, and ownership in your application database.
On upload, set object metadata such as content-type: image/jpeg. Clients can then render the response correctly instead of guessing from a filename. On reads, the sequence is straightforward:
- Authenticate the customer and load the avatar record.
- Confirm that the record belongs to that customer or to a permitted account.
- Request a short-lived signed URL for the exact object key.
- Return that URL to the browser; the browser fetches the image directly.
The application key belongs only on the API request. Never forward it to the returned URL.
Here is a minimal TypeScript helper using the documented presign route. It checks status codes, honors Retry-After for rate limits, and uses an idempotency key so a retried presign request has one client identity. The actual authorization decision remains in your application code before this function runs.
const baseUrl = process.env.INFRAI_BASE_URL;
function retryDelay(response: Response, attempt: number): number {
const retryAfter = Number(response.headers.get("retry-after"));
if (Number.isFinite(retryAfter) && retryAfter > 0) return retryAfter * 1000;
return Math.min(1000 * 2 ** attempt, 8000);
}
export async function presignAvatar(bucket: string, key: string): Promise<string> {
const apiKey = process.env.INFRAI_API_KEY;
if (!apiKey || !baseUrl) throw new Error("INFRAI_API_KEY and INFRAI_BASE_URL are required");
for (let attempt = 0; attempt < 4; attempt += 1) {
const response = await fetch(
`${baseUrl}/storage/object/presign/${encodeURIComponent(bucket)}/${key
.split("/")
.map(encodeURIComponent)
.join("/")}`,
{
method: "POST",
headers: {
Authorization: `Bearer ${apiKey}`,
"Content-Type": "application/json",
"Idempotency-Key": `avatar-read-${bucket}-${key}`,
},
body: JSON.stringify({}),
},
);
if (response.status === 429) {
await new Promise((resolve) => setTimeout(resolve, retryDelay(response, attempt)));
continue;
}
if (!response.ok) {
const detail = await response.text();
throw new Error(`presign failed (${response.status}): ${detail}`);
}
const payload = (await response.json()) as { url?: string };
if (!payload.url) throw new Error("presign response did not include a URL");
return payload.url;
}
throw new Error("presign rate limit did not clear after retries");
}
The exact request fields for expiry should come from the capability schema your client uses; I am not guessing them here. Your mileage may vary on how long a profile page stays open, but a short lifetime is usually easier to reason about than a token that effectively never expires.
The catch is real. There is no public-read ACL, object versioning, object lock, or cross-region automatic replication in the stated storage capability. Lifecycle expiry cannot be shorter than one day, metadata is not a server-side search index, and browser-direct uploads cannot rely on a separately configurable CORS route. Strict compare-and-swap writes also need coordination in your database or a queue because conditional If-Match writes are unavailable.
Stick with S3 when WORM retention, cross-region controls, or a large migration ecosystem is non-negotiable. Choose R2 when the public edge is the product and Workers are already your policy layer. Choose Azure Blob when Azure-native identity and governance outweigh a multi-service design. Infrai is a reasonable fit when authenticated delivery and a plain HTTP integration matter more than those missing storage controls.
What should an auth app choose for private avatar storage, signed URLs, or a public CDN URL?
Before launch, check these storage limits against your retention and governance requirements.
The most common mistake is treating a signed URL as permanent. It is not. Cache the object in the browser for the page lifetime, refresh the capability when the user revisits the page, and keep the canonical object key in your database. A stale URL should produce a clean refresh path, not a leaked long-lived credential.
The second mistake is overwriting a key such as users/42/avatar.jpg while two uploads race. Use a UUID key, write the new database record, and retire the old object through an explicit job. I initially thought a single stable key would make cache invalidation simpler; it actually made concurrent updates harder to audit. A unique key plus a current-pointer row is easier to explain when something goes wrong. In a busy account-settings flow, that extra row also gives the worker a clear handoff: it can compare the old and new keys, preserve the winning record, and remove only objects that no longer have a database owner. That is slower to describe than a single overwrite, but far easier to inspect during a support ticket.
Finally, list by the user prefix for repair work, not for every page render. Listing only supports prefix organization, and object metadata cannot be queried as application data. Keep the ownership check and content type in the database, then use a HEAD check when a worker needs to verify that the referenced object exists.
If avatars must be permanently public, this private design is not suitable. Add the proxy or select a storage/CDN combination with an explicit public delivery contract. Security and throughput are both valid goals; the right answer depends on which one your URL is allowed to expose.
A governance check before you commit
Write down who may issue a signed read, how object ownership is recorded, and which external system handles immutable retention if you need it. That short policy is more valuable than a permanent URL copied into a template.
References
- https://docs.aws.amazon.com/AmazonS3/latest/userguide/using-presigned-url.html
- https://docs.aws.amazon.com/AmazonS3/latest/userguide/UsingMetadata.html
- https://developers.cloudflare.com/r2/api/s3/presigned-urls/
- https://learn.microsoft.com/en-us/azure/storage/blobs/sas-service-create
- https://aws.amazon.com/s3/pricing/
Top comments (0)