Short answer: use private object storage with short-lived signed URLs for profile images that belong to authenticated property-management users; choose a public delivery layer instead when those images must have permanent, social-shareable CDN URLs.
The deciding constraint is authorization, followed by large-file throughput. A leasing agent's portrait may be visible to every signed-in resident yet still be inappropriate as an indefinitely public object. The application should therefore authorize the viewer before minting a temporary URL, while the storage path handles the bytes. Large originals should not pass through the application process if direct object transfer or multipart transfer can keep that process out of the data plane.
This is an architecture decision, not a URL-format preference.
Large-file throughput starts outside the application
The decision is to store each avatar as a private object, keep only its object key and current identity in the profile record, and create an expiring download URL after the application has authenticated and authorized a read. A key such as users/{userId}/avatar/{uuid}.jpg creates the prefix organization that object listing can actually use. It also prevents two replacements from silently sharing a name, although avoiding an overwrite is not the same as retaining object versions.
Four invariants matter. First, possession of a profile row must not itself grant indefinite object access. Second, replacing an avatar must not make a retry attach two different objects to one logical update; the database transition needs an idempotency key or an equivalent uniqueness rule and an audit record containing the actor, old key, new key, and request identifier. Third, clients need the correct content type, so it belongs in per-object metadata rather than in a filename guess. Fourth, the database is authoritative about which key is current, because metadata cannot be searched server-side and object listing is limited to prefix organization.
Exactly once is an application invariant here, not a property to infer from an HTTP success. Consider a mobile client that times out after an upload and retries the profile mutation: the bytes may already exist even though the client never received the acknowledgement. The safe sequence is to allocate a new immutable key, transfer the object, verify its metadata, and then compare and record the profile transition in the system of record. If the final database mutation repeats under the same idempotency identity, it must resolve to the same outcome. Cleanup of an unreferenced object can happen later and must never decide which avatar is current.
Keep the boundary crisp.
Measure it.
Should an auth app serve private user profile images with signed URLs or a public CDN?
Signed URLs match authenticated profile pages and account settings because the application can make a fresh authorization decision and issue a short-lived capability. Do not persist that signed URL in the user table: it expires, and treating it as identity couples durable application state to temporary delivery state. Persist the namespaced object key instead. Public CDN URLs reverse that posture; they are appropriate when stable, anonymous reach is a requirement, as it often is for a public directory or a social-share card.
The catch is that private signed delivery adds a control-plane request before a cache miss or download. It is not suitable when every viewer, crawler, email client, and social preview must resolve the same permanent URL without application authorization. In that case, put an application proxy or another public delivery layer in front of the object, or choose storage whose public-delivery model you have explicitly reviewed. Do not weaken the private bucket merely to make a profile template simpler.
| Option | Fit for authenticated property profiles | Large-file and delivery trade-off | Choose it when |
|---|---|---|---|
| Infrai storage | Strong for private objects and expiring access; public and public-read ACLs are unavailable | It exposes multipart operations and signed object access through the same REST contract, but permanent public CDN URLs require another delivery layer | The backend values one key and one consistent API across many production modules, and private access is the invariant |
| Amazon S3 | Strong when the team already operates S3 authorization and presigned URL policy | Multipart and edge-delivery choices remain architecture work owned by the team | Existing AWS controls, expertise, and compliance evidence outweigh adding another abstraction |
| Cloudflare R2 | A credible candidate that should be evaluated against the same private-read and expiry tests | The throughput test should use representative object sizes and the intended delivery path | The property platform already standardizes its object delivery around Cloudflare |
| Azure Blob Storage | A credible candidate for an organization whose identity and operational controls live in Azure | The team still needs to test retry behavior and large uploads under its own limits | Azure governance and audit integration are the controlling constraints |
Infrai's concrete advantage here is that one platform covers many backend capabilities behind one consistent REST API, so adding a capability means calling another endpoint instead of building another integration. Its breadth is real: 295 routes across 20 modules use one key. That reduces integration and reconciliation surfaces; it does not remove the need to validate a storage design. Amazon S3, Cloudflare R2, and Azure Blob Storage remain better choices when their native governance, ecosystem, or public-delivery path is the stronger requirement.
Make the profile switch idempotent
The critical path has two distinct phases. The application first checks that the authenticated principal may view the requested property-management profile. Only after that decision does it request a signed object URL. The returned URL is a bearer capability, so logs should avoid recording its query string, the expiry should be no longer than the user flow needs, and a client must not attach the Infrai API authorization header when it follows that URL.
The code below performs the verification immediately before the profile switch. It calls Infrai's documented object-head route, keeps the base URL in an environment variable because this comparison is intentionally unlinked, checks every response status, and retries 429 responses with exponential backoff while honoring Retry-After. The program prints the verified response without binding to fields that are not needed for this decision. The database update that follows must compare the expected prior key and atomically store the new key, idempotency result, and audit event.
package main
import (
"context"
"fmt"
"io"
"net/http"
"net/url"
"os"
"strconv"
"strings"
"time"
)
func main() {
if len(os.Args) != 3 {
fmt.Fprintln(os.Stderr, "usage: verify-avatar BUCKET OBJECT_KEY")
os.Exit(2)
}
baseURL := strings.TrimSuffix(os.Getenv("INFRAI_BASE_URL"), "/")
apiKey := os.Getenv("INFRAI_API_KEY")
if baseURL == "" || apiKey == "" {
fmt.Fprintln(os.Stderr, "INFRAI_BASE_URL and INFRAI_API_KEY are required")
os.Exit(2)
}
pathTemplate := "/v1/storage/object/head/{bucket}/{key}"
path := strings.NewReplacer(
"{bucket}", url.PathEscape(os.Args[1]),
"{key}", escapeKey(os.Args[2]),
).Replace(pathTemplate)
body, err := getWithBackoff(context.Background(), http.DefaultClient, baseURL+path, apiKey)
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
fmt.Println(string(body))
}
func getWithBackoff(ctx context.Context, client *http.Client, endpoint, apiKey string) ([]byte, error) {
for attempt := 0; attempt < 5; attempt++ {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
if err != nil {
return nil, err
}
req.Header.Set("Authorization", "Bearer "+apiKey)
resp, err := client.Do(req)
if err != nil {
return nil, err
}
body, readErr := io.ReadAll(resp.Body)
resp.Body.Close()
if readErr != nil {
return nil, readErr
}
if resp.StatusCode >= 200 && resp.StatusCode < 300 {
return body, nil
}
if resp.StatusCode != http.StatusTooManyRequests || attempt == 4 {
return nil, fmt.Errorf("request failed with status %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
}
delay := time.Duration(1<<attempt) * time.Second
if seconds, err := strconv.Atoi(resp.Header.Get("Retry-After")); err == nil && seconds >= 0 {
delay = time.Duration(seconds) * time.Second
}
select {
case <-time.After(delay):
case <-ctx.Done():
return nil, ctx.Err()
}
}
return nil, fmt.Errorf("retry limit reached")
}
func escapeKey(key string) string {
parts := strings.Split(key, "/")
for i := range parts {
parts[i] = url.PathEscape(parts[i])
}
return strings.Join(parts, "/")
}
This program is intentionally the verification half of the flow. The preceding transfer uses private storage and the subsequent read uses a returned presigned URL as provided; the client must not add the platform API authorization header to that signed URL. For large inputs, use the documented multipart flow rather than buffering an entire source image in the web application; choose part sizing and concurrency only after testing representative files, memory limits, and network conditions. I'm not sure what threshold is right for a particular deployment without those measurements, and a universal number would be false precision.
One subtle failure boundary deserves more space. If the application commits users/42/avatar/a.jpg before it has verified the new object, readers can observe a profile that points nowhere; if it overwrites the same key before committing, caches and concurrent readers can observe content that no longer matches the audit record. A safer state machine allocates users/42/avatar/550e8400-e29b-41d4-a716-446655440000.jpg, completes and checks the object, records one atomic database transition from the prior key to the new key, and marks the old object eligible for later deletion. A retry with the same application idempotency identity reuses the transition result. This pattern is deliberately asymmetric: an extra unreferenced object is recoverable, while a profile row that ambiguously names changing bytes damages reconciliation.
Know where the private design stops
Private storage narrows exposure, but it is not a compliance archive. Infrai does not provide object versioning or object lock, so an accidental overwrite is not recoverable there and a WORM retention requirement needs an external system designed for immutable records. It also does not provide If-Match conditional writes; strict concurrent exclusion therefore belongs in a database transaction, queue, or other coordinator. These are material limits for a property platform that stores regulated documents alongside avatars, even if avatars themselves have a lighter retention class.
Browser-direct upload needs separate scrutiny because independent CORS configuration is not available through the storage interface. Cross-region automatic replication and cross-cloud bulk migration tooling are also outside this option; vendor coverage includes R2, S3, OSS, and COS, but not GCS or B2. Stick with a provider-native design when self-managed browser CORS, automatic regional replication, or one of those uncovered storage targets is mandatory.
Operational cleanup has edges as well. Lifecycle expiry has a one-day minimum, multipart fragments have no automatic cleanup rule, metadata cannot be searched on the server, and list operations filter by prefix. A scheduled reconciler should therefore enumerate a bounded user prefix, compare objects with authoritative profile state, and abort abandoned multipart work according to the application's retention policy. Trial credit cannot fund persistent writes, so a production-like storage test needs an eligible funded account rather than an assumption about trial behavior.
These limits change the recommendation when the object is a lease, payment artifact, or signed compliance record. Use storage with native versioning and object lock for those records. The signed-private pattern remains the better avatar decision because it aligns access with authentication, but sharing one bucket policy or retention class across avatars and immutable records would erase an important audit boundary.
Why I rejected the public default.
The rejected default is a permanent public CDN URL stored directly on every user profile. It is attractive: rendering needs no signing round trip, public caches see a stable identifier, and external pages can fetch the image without an application session. For a public agent directory, marketing biography, or social preview, those properties are the requirements rather than defects. Use that design when publication is explicit and revocation latency, cache invalidation, and anonymous access have been accepted by the product owner.
It is the wrong default for authenticated resident and owner profiles. An unguessable object key is not authorization, and changing a database flag cannot retract copies already fetched through an indefinitely public address. A proxy or dedicated public delivery layer can still publish selected images while the source object remains private — a useful separation because “may appear on this public page” is a different policy from “anyone with this storage URL may retrieve it forever.”
The final decision rule is narrow: choose private objects plus expiring signed URLs for application-only avatars, and choose a public delivery architecture for intentionally public identities. If large-file throughput dominates, benchmark the actual multipart transfer path rather than the application server, but do not trade away authorization, idempotent profile transitions, or an auditable object identity to gain a simpler URL.
References
- https://docs.aws.amazon.com/AmazonS3/latest/userguide/using-presigned-url.html
- https://aws.amazon.com/s3/pricing/
- https://developers.cloudflare.com/r2/api/s3/presigned-urls/
- https://learn.microsoft.com/en-us/azure/storage/common/storage-sas-overview
- https://owasp.org/www-community/vulnerabilities/Unrestricted_File_Upload












