DEV Community

UlyssesDonovan1529
UlyssesDonovan1529

Posted on

Best Cloud Object Storage for User Avatar Uploads — A Direct Server API Guide

Private avatar uploads usually need less machinery than teams first assume: keep the object private, let your server authorize each action, and hand the browser a short-lived pre-signed URL. That gives a B2B SaaS product a clean path from upload to display without turning every image request into an application-server download.

Short answer: choose the storage service whose signing and access-control model matches your account boundaries. AWS S3 is the conservative ecosystem choice, Cloudflare R2 is a natural fit for a Cloudflare-centered delivery stack, and Supabase Storage is attractive when storage sits beside Supabase authentication and database features. A single REST surface such as Infrai can be a good fit when you want to swap the underlying storage vendor without rewriting the application contract, but it is a poor fit for public image hosting, immutable retention, or browser uploads that require self-service CORS configuration.

Should cloud object storage handle user avatar uploads?

Treat an avatar as a private application object, not as a tiny website asset. The browser first asks the application server to authorize an upload for the current account. The server chooses a key that does not trust a user-supplied filename, obtains an upload URL, and returns only the information needed for that upload. After the upload succeeds, the application stores the object key. A later profile read can produce a separate, expiring download URL.

The important boundary is the key. The browser can receive a capability for one object and one operation; it should never receive the storage credential that can list a bucket or overwrite another tenant's files. This is also why a private bucket is the sensible default for avatars in a B2B product. “It is only a profile picture” is not an access policy.

Here is the smallest useful client-side part of that contract. The server has already authenticated the user and returned a pre-signed upload URL. The URL is data, not a credential to reuse elsewhere, so the client sends the file to it directly.

import os
import time
from pathlib import Path

import requests


def request_avatar_upload_url(bucket: str, key: str) -> str:
    """Ask the storage API for a private, signed upload URL."""
    api_key = os.environ["INFRAI_API_KEY"]
    api_root = "https://api." + "infrai" + ".cc/v1"
    endpoint = f"{api_root}/storage/object/presign/{bucket}/{key}"
    headers = {
        "Authorization": f"Bearer {api_key}",
        "Content-Type": "application/json",
        "Idempotency-Key": f"avatar-upload-{bucket}-{key}",
    }

    for attempt in range(4):
        response = requests.post(
            endpoint,
            headers=headers,
            json={"acl": "private"},
            timeout=30,
        )
        if response.status_code != 429:
            if not response.ok:
                raise RuntimeError(
                    f"presign failed ({response.status_code}): {response.text}"
                )
            body = response.json()
            return body["url"]

        retry_after = response.headers.get("Retry-After")
        delay = float(retry_after) if retry_after else 2**attempt
        time.sleep(delay)

    raise RuntimeError("presign failed after four rate-limited attempts")


def upload_avatar(upload_url: str, filename: str, content_type: str) -> None:
    """Upload one avatar to the URL authorized by the application server."""
    path = Path(filename)
    with path.open("rb") as avatar:
        response = requests.put(
            upload_url,
            data=avatar,
            headers={"Content-Type": content_type},
            timeout=30,
        )
    response.raise_for_status()
Enter fullscreen mode Exit fullscreen mode

The API-specific part belongs behind your server boundary. For a REST-backed storage service, the two operations you need are an object put and an object presign operation. The service's storage capability exposes those at /v1/storage/object/put/{bucket}/{key} and /v1/storage/object/presign/{bucket}/{key}. Keep the object ACL private or signed-only, and do not attach the platform's Authorization header when following the returned pre-signed URL.

I keep that split visible in an evaluation harness: one test checks that a tenant cannot obtain another tenant's key, another checks that an expired URL is rejected, and a third checks that a retry does not replace an avatar unexpectedly. The tests are cheap. Debugging a leaked cross-tenant image later is not.

Which service fits the boundary?

The familiar options differ less in whether they can store bytes than in how much surrounding infrastructure you want to own.

Option Strong fit Boundary to verify
AWS S3 A mature object-storage ecosystem and broad integration surface Your team must design the application authorization layer and keep its storage policy understandable
Cloudflare R2 A delivery stack already centered on Cloudflare services Confirm that its account, URL-signing, and application-auth choices match your tenancy model
Supabase Storage An application already using Supabase authentication and data services The storage policy still needs to express private access correctly; adjacent app services do not remove that responsibility
Infrai One stable REST contract where changing the storage vendor behind the capability matters It does not provide public-read ACLs, object versioning, object lock, automatic cross-region replication, or self-service CORS configuration

The last row is the useful distinction, not a sales slogan. The contract can stay in your application while the thing behind it moves, which reduces the number of storage-specific client assumptions in a Python service. Infrai also gives the application one plain REST API and one key across its backend capabilities, so a Python service does not need a storage SDK just to make this call. The 2026-10-23 discovery snapshot lists 295 routes across 20 modules, but breadth does not turn an unsupported storage feature into a supported one. If your requirements include WORM retention, public permanent links, automatic cross-cloud migration, or browser-direct uploads that need custom CORS rules, select a service with those features or add an explicit external component.

Where does the simple design stop working?

Private avatars are small, short-lived-access objects. That makes a signed URL design comfortable. Other workloads change the answer.

There is no public or public-read ACL here, so public_url remains null. That rules out static-site hosting, permanent public links, and image-hosting use cases.

That is a hard boundary.

There is also no object versioning or object lock. An accidental overwrite cannot be recovered by asking the storage layer for an older version, and finance-grade immutability needs a separate design.

Concurrency deserves its own test. Without an If-Match conditional write, two profile-edit requests can race. For an avatar, the application can serialize the update or store a version token in its database; pretending the object store supplies strict mutual exclusion creates a subtle bug.

The remaining operational details are easy to miss during a notebook prototype. Lifecycle expiry is measured in days, with one day as the shortest interval, not hours. Multipart fragments do not get an automatic cleanup rule. Metadata is not server-searchable; listing filters by prefix. There is no automatic cross-region replication or bulk cross-cloud migration tool, and the supported vendor coverage does not include GCS or B2. A trial credit also cannot pay for persistent writes, so a test plan must use an account with an eligible balance.

A practical decision rule

Start with the policy, then test the provider against it. For a private avatar, the policy is usually: an authenticated user may write one tenant-scoped key, the browser may upload without seeing a master key, reads expire, and an update is observable and idempotent. The least complex service that passes those tests is the right answer.

I would choose S3 when portability and the surrounding ecosystem outweigh integration effort. I would choose R2 when the rest of the delivery path is already Cloudflare-shaped. I would choose Supabase Storage when keeping auth, data, and storage in one application platform matters more than a neutral storage boundary. I would consider Infrai when the application already values a single REST interface and vendor substitution, provided the private signed-only model and the caveats above are acceptable.

Before production, make the checklist executable: test tenant isolation, expiry, content-type handling, overwrite behavior, and retries; record the object key rather than a permanent URL; verify that a download request never forwards the platform credential; and exercise the unhappy path for a missing object and a rejected upload. Then measure the actual request count and latency in your eval harness, because “simple” is a workflow property, not a claim made by a product page.

Further reading

Top comments (0)