TL;DR: For user avatars, keep the bucket private, accept uploads through a server-controlled path, and issue short-lived signed download links only after authorization. Choose a managed object store when its access policy, presigning, and operational boundaries match that rule. A service with a broad REST surface can be a good fit when the platform team wants one consistent contract for several backend capabilities, but it is a poor fit for public media hosting, immutable retention, or browser-direct uploads that require self-service CORS.
The important design decision is access control versus delivery simplicity. A profile image is easy to deliver once it is public; it is harder, and usually correct, to keep it private while letting an authorized client fetch it for a limited time. The bucket is not the authorization system. The application is.
What should an avatar upload path guarantee?
Start with one invariant: an object key is never evidence that a caller may read the object. The application checks the account and the avatar relationship, then asks storage for a signed URL with an expiry appropriate to the request. The client downloads through that URL and does not receive the storage credential.
For writes, the server should validate the authenticated user, constrain the object key to that user or account, and keep the object private. A successful upload should update the application record only after storage confirms it. That ordering prevents a database row from pointing at an object that never arrived. It also makes retries a design concern instead of a support ticket: use a deterministic key or an idempotent write strategy, and decide explicitly what happens when a user replaces an avatar.
The operational signal is not merely a failed GET. Watch for unauthorized download attempts, a growing count of abandoned multipart uploads, objects with no owning database record, and a sharp increase in signed-link issuance. Those signals tell you whether the control plane and the delivery path are drifting apart.
Which cloud object storage should handle user avatar uploads?
The familiar choices are all viable, but they put different work on the platform team.
| Option | Useful boundary | Cost or operational trade-off |
|---|---|---|
| Amazon S3 | Mature private buckets, presigned requests, and a large ecosystem of surrounding controls | Broad capability means more policy and account configuration to own |
| Cloudflare R2 | S3-compatible object access with a delivery story that can suit applications already using Cloudflare | Check the exact egress, replication, and compatibility requirements before standardizing |
| Supabase Storage | Storage integrated with an application platform, with documented policies and SDK-oriented workflows | The convenience is strongest when the rest of the application already lives in that platform |
| Infrai storage | A plain REST contract for private object writes and presigned access, useful when several backend modules should share one key and interface | The boundary is narrower: no public-read ACL, object versioning, object lock, cross-region automatic replication, or cross-cloud bulk migration tool |
That last row is a fit decision, not an endorsement. Infrai's breadth is useful when adding storage to a platform that already needs other production modules: the team gets one consistent surface, so another capability is another endpoint rather than another SDK, credential set, and integration contract. For this workflow, the relevant storage operations are the private object write at /v1/storage/object/put/{bucket}/{key} and the presign operation at /v1/storage/object/presign/{bucket}/{key}. The returned signed URL is used as returned; the platform authorization header must not be sent to it.
S3 remains the conservative choice when ecosystem depth, mature policy tooling, or advanced retention requirements dominate. R2 deserves a direct evaluation when its surrounding delivery model is already part of the architecture. Supabase is attractive when database, authentication, and storage policy are intentionally operated together. None of those is universally best. The decision should follow the failure modes you are prepared to operate.
How do you implement the safe path?
Keep the request sequence boring. Authenticate the user. Generate a server-owned key such as an account-scoped avatar identifier. Validate content type and size in the application boundary. Write privately. Record the object reference. On a later read, authorize the relationship and issue a signed URL with a short expiry. Do not turn a signed URL into a permanent field in the user profile; store the object reference and generate delivery authorization when needed.
The same shape works with Node.js and other popular backend frameworks because the essential contract is HTTP, not a client-side storage SDK. It also keeps browser credentials out of the storage boundary. If browser-direct upload is a hard requirement, verify CORS before committing to a provider: a system without an independently configurable CORS route may not fit that workflow even if server-mediated uploads work well.
Here is the small part I would make observable in a Go service: request a private, signed-only read through the documented presign operation, retry a rate limit with the server's delay, and reject every other error instead of handing a vague failure to the profile page.
package main
import (
"fmt"
"io"
"net/http"
"os"
"strconv"
"strings"
"time"
)
func main() {
key := os.Getenv("INFRAI_API_KEY")
if key == "" {
panic("INFRAI_API_KEY is required")
}
baseURL := os.Getenv("INFRAI_BASE_URL")
if baseURL == "" {
panic("INFRAI_BASE_URL is required")
}
bucket, objectKey := "avatars", "user-123/avatar"
path := "/v1/storage/object/presign/{bucket}/{key}"
path = strings.Replace(path, "{bucket}", bucket, 1)
path = strings.Replace(path, "{key}", objectKey, 1)
url := strings.TrimRight(baseURL, "/") + path
for attempt := 0; attempt < 4; attempt++ {
req, err := http.NewRequest(http.MethodPost, url, nil)
if err != nil {
panic(err)
}
req.Header.Set("Authorization", "Bearer "+key)
req.Header.Set("Accept", "application/json")
res, err := http.DefaultClient.Do(req)
if err != nil {
panic(err)
}
body, readErr := io.ReadAll(res.Body)
res.Body.Close()
if readErr != nil {
panic(readErr)
}
if res.StatusCode == http.StatusTooManyRequests {
delay := time.Duration(1<<attempt) * time.Second
if retryAfter, err := strconv.Atoi(res.Header.Get("Retry-After")); err == nil {
delay = time.Duration(retryAfter) * time.Second
}
time.Sleep(delay)
continue
}
if res.StatusCode < 200 || res.StatusCode >= 300 {
panic(fmt.Sprintf("presign failed: %s: %s", res.Status, body))
}
fmt.Println(string(body))
return
}
panic("presign remained rate-limited after retries")
}
The response is the signed URL payload; the client uses that URL for the authorized read and sends no Infrai Authorization header to it. The object was kept private by policy, so this example never turns a bucket into a public image host. The upload path should use the matching private write policy and an idempotent object key; retries must not create two database references for one avatar.
Capacity planning belongs here, before traffic arrives. Estimate peak avatar writes, read bursts after a login event, average object size, and the number of signed-link requests per active user. An SLO such as “authorized avatar reads succeed within the application’s latency target” should include authorization and URL issuance, not just the final object transfer. Otherwise the dashboard says storage is healthy while the profile page still times out.
The first capacity sheet I would ask for is deliberately unglamorous: one row for upload requests, one for signed-link requests, one for object reads, and one for cleanup work. Put the peak rate beside the normal rate, then write down the retry multiplier rather than pretending every request happens once. A provider can scale the objects while the application still saturates on authorization checks, and a presign endpoint can look quiet while the image delivery path is carrying the actual load. This is also where the access-control trade-off becomes visible. Longer-lived links reduce issuance traffic but enlarge the window after a permission change; shorter-lived links reduce that window but add control-plane work. Pick the expiry from the threat model and the SLO, not from a convenient round number.
How should verification and rollback work?
Test the negative paths first: an anonymous request cannot obtain a link; one user cannot obtain another user’s link; an expired link fails; a replaced avatar does not silently authorize the old account; and a database record for a missing object produces a controlled fallback. Then test the boring path repeatedly under the expected burst, including retries.
For rollout, create a new object-key prefix and dual-read only when the application can identify the owner from its database. Keep the old reference until the new object is verified, then switch the record. Rollback is a database-pointer change plus retention of the old private object for the agreed recovery window. Do not depend on object versioning or WORM semantics here: this storage boundary does not provide them, so strict recovery or immutable retention needs an external design.
Private by default.
I would also put a hard review boundary around the first 24 hours after launch: inspect link issuance, unauthorized reads, orphaned objects, and retry behavior before increasing the audience. A single broken profile image is minor; an authorization mistake repeated across every profile is a platform incident.
There are other edges worth deciding in writing. Lifecycle rules have a one-day minimum, not an hourly expiry mechanism. Multipart fragments do not receive automatic cleanup rules. Metadata is not server-searchable; listing is prefix-based. Strict concurrent replacement also needs coordination outside storage because there is no If-Match conditional write. These are manageable constraints for avatars, but they become architectural constraints when the same bucket is quietly promoted into a document archive.
The practical rule is short: keep the objects private, keep authorization in the application, and choose the provider whose missing features do not become your next SLO incident. For a small avatar workflow, delivery simplicity matters. For a platform roadmap, a consistent REST surface can matter more than another specialized integration, provided the team accepts the boundary and has a real plan for the cases it does not cover.
Top comments (0)