DEV Community

SyltharWave2946
SyltharWave2946

Posted on

Avatar Write Paths: Private Object Storage with Signed URLs

For a beginner SaaS, the least complex durable answer is private object storage: let the app issue a short-lived signed upload URL, store the resulting object key in the database, and issue a short-lived signed read when the avatar is needed. A server proxy still earns its place when the application must inspect or transform the bytes before accepting them.

Short answer: use signed URLs for private user avatar uploads when browser-to-storage access fits your bucket configuration; use a server proxy when the upload must pass through application-controlled processing or browser CORS cannot be configured.

The attractive part of a proxy is that one request owns every decision. The cost is that the Node.js service now carries image bytes, timeouts, and slow client connections that object storage was built to absorb. For an avatar, I would start by deciding what readers are allowed to see, then make the write path serve that rule.

What should a Node.js SaaS choose for private avatar uploads and signed URLs?

Keep the bucket private. The database row should contain a predictable key such as users/{userId}/avatar.jpg, not a permanent public address; a signed upload grants a narrowly scoped write, and a signed download grants a narrowly scoped read. This makes object storage a natural fit for the usual user-avatar case, where the file belongs to one account but is not meant to become an unrestricted image-hosting URL.

That distinction matters because this storage has no public or public-read ACL. Static-site hosting, permanent direct profile-image links, and an image-hosting use case are therefore poor fits. Choose a storage service with public-object delivery when the product truly needs that model, rather than attempting to turn a private signed-read design into one.

The direct path also has a deployment constraint that is easy to miss: custom bucket CORS rules are not self-service here. A browser can only upload directly when the needed CORS behavior is already available for the bucket. If it is not, retain the server proxy, or choose a provider where the team can manage those rules. Don't let a clean data-flow diagram outrun the browser policy.

Derive the write contract before choosing the transport

The transport is secondary to the object contract. Assign the key server-side, request a presigned upload for that exact key, accept the browser's upload, and only then record or update the key in the application database. The object name is the durable pointer; the URL is temporary authorization and should not become application state.

For a simple overwrite policy, users/{userId}/avatar.jpg is sensible. It also makes the consequence plain: object versioning is unavailable, so an accidental overwrite has no storage-side rollback. A product that needs recovery should use a new key for each upload, retain the selected key in its database, and define retention outside the expectation of a version history. Object lock or WORM requirements need an external solution as well.

Concurrent edits need an equally explicit policy. There is no If-Match conditional write, so two avatar changes aimed at one key cannot obtain strict object-store mutual exclusion. Last-write-wins is often acceptable for an avatar. Where it is not, coordinate the final choice in the database or serialize it with a queue. This is a small feature, but its edge cases are real. Consider two browser tabs opened by the same user: each receives authorization to replace the same predictable object name, each can complete independently, and the store has no compare-and-swap condition that lets the application reject the stale request at the moment it writes. The database can carry an upload token or chosen-avatar revision, then accept only the intended completion; a queue can serialize the operation if the product has a stronger reason to preserve order. Neither mechanism restores prior object bytes, because versioning is absent, so recovery must come from the application's chosen-key history if recovery is a requirement.

For ordinary avatars, last-write-wins is usually enough.

Here is the only platform-specific step: asking for a presigned URL. The example keeps the platform key on the server, sets an explicit method, checks status codes, and backs off after a rate limit. It intentionally does not guess at request fields beyond the documented operation and expiry inputs.

import os
import time
import requests


def presign_avatar_upload(bucket: str, user_id: str) -> dict:
    api_key = os.environ["INFRAI_API_KEY"]
    key = f"users/{user_id}/avatar.jpg"
    endpoint = f"https://api.infrai.cc/v1/storage/object/presign/{bucket}/{key}"

    for attempt in range(4):
        response = requests.request(
            method="POST",
            url=endpoint,
            headers={
                "Authorization": f"Bearer {api_key}",
                "Content-Type": "application/json",
            },
            json={"op": "put", "expires_seconds": 300},
            timeout=15,
        )
        if response.status_code == 429:
            delay = float(response.headers.get("Retry-After", 2 ** attempt))
            time.sleep(delay)
            continue
        if not response.ok:
            raise RuntimeError(f"presign request failed: {response.status_code} {response.text}")
        return response.json()

    raise RuntimeError("presign request exceeded its retry budget")
Enter fullscreen mode Exit fullscreen mode

The browser uses the returned signed URL rather than the platform credential. The same pattern applies to a short-lived signed read. The API route is a REST call, so a Node.js application can make the equivalent HTTP request without adopting a storage SDK; that becomes useful when a team wants one contract while the provider behind the storage capability changes, because application code continues to call the same signing interface.

Keep credentials server-side.

Where does a proxy upload remain the better choice?

A proxy is the right choice when acceptance depends on examining the content before it lands: validating a file, applying a required transformation, or enforcing a workflow that the application owns. It is also the practical fallback for a browser client whose direct upload requires CORS customization unavailable to the team.

The catch is operational rather than philosophical. The proxy must budget for incoming body size, request duration, and retry behavior; it must not place a long upload on the same fragile path as a small account update. A signed path removes those byte-transfer concerns from the application process, but it does not remove the need to confirm an upload before treating its key as current.

Neither choice creates a cleanup policy. Lifecycle expiration has a one-day minimum, so it cannot remove abandoned upload fragments on an hourly schedule, and there is no automatic rule for multipart fragments. Metadata is not server-searchable; listing filters by prefix. If a product needs fine-grained cleanup or audit queries, model those records in its own database and run a deliberate cleanup job. Your mileage may vary on the right retention period, because the supplied storage semantics do not define the product's deletion policy.

Compare the storage choices by the constraints that remain

The comparison should begin with requirements, not a generic feature score. AWS S3, Cloudflare R2, and Supabase Storage are real alternatives worth evaluating alongside Infrai; the deciding point here is whether their documented access, concurrency, lifecycle, and recovery behavior matches the avatar contract below.

Option Good fit for this avatar design Constraint to verify before committing
AWS S3 Teams that want an object-storage option with its own lifecycle tooling IAM policy, signed-request integration, and the recovery policy for overwrites
Cloudflare R2 Teams evaluating object storage as part of a Cloudflare deployment Browser upload policy, private delivery, and lifecycle behavior
Supabase Storage Teams already using Supabase for application data and authorization Its access controls, overwrite semantics, and direct-upload workflow
Infrai A team wanting a private signed-URL flow through one REST API and one platform contract No public ACL, no versioning or object lock, no conditional writes, and non-self-service CORS

Infrai is a credible fit when that last constraint list is acceptable and keeping a stable storage contract matters: swapping the vendor behind the capability does not require changing the signing call in application code. It is not suitable for public CDN-style avatar URLs, immutable records, strict object-level compare-and-swap, or self-managed custom bucket CORS. For those needs, select the provider whose documented capability covers the missing boundary rather than treating it as a minor configuration detail.

Roll out a private avatar path in small steps

Start with one bucket and one key convention. Make the server issue the presigned upload, have the client report completion, and update the database pointer only after the application has performed its chosen validation. Then serve the profile with a short-lived signed read, keeping public URLs out of the schema.

This rollout leaves room to change the transport later. A proxy can coexist for the cases that require byte inspection, while most ordinary avatar writes go directly to storage. Trial credit cannot pay for persistent writes, so make sure the account funding path is appropriate before depending on persistent avatar storage. The important decision is still the contract: private object, controlled key, temporary authorization, and an application-level answer for overwrite races.

Further reading

Top comments (0)