DEV Community

JamesAnderson121
JamesAnderson121

Posted on

Private User Uploads: Tenant Isolation, Expiring Links, and Storage Choices

Private avatar uploads are easy to demo and surprisingly easy to isolate badly. Short answer: choose a storage service with a mature server-side SDK, keep every object private, put the tenant boundary in the object key and authorization check, and issue short-lived download links from your backend. The storage brand matters less than whether that contract is explicit and easy to test.

For a customer-support application, I would keep the browser away from permanent object URLs. The backend authenticates the user, verifies the tenant, and creates an expiring link only after that check. This makes a profile image a scoped capability rather than a guessable file path. It also keeps deletion possible when a user invokes the right to erasure.

The constraint that changes the recommendation is tenant isolation. A fast upload is not a successful upload if a support agent from tenant A can retrieve tenant B's avatar by changing one identifier. I initially thought the bucket was the security boundary. That was too coarse. The useful boundary is the combination of application authorization, an unambiguous key layout, private objects, and a short download lifetime.

What does the upload flow need to guarantee?

The flow has four distinct decisions:

  1. Authenticate the caller and resolve the tenant on the server.
  2. Generate an object key from trusted identifiers, such as tenants/{tenant_id}/avatars/{user_id}.webp.
  3. Write the object without making it public.
  4. Generate a signed download URL only after checking that the requester belongs to the same tenant.

The filename supplied by a browser is not part of that trust decision. It is input. Treat it as metadata, or discard it. A stable server-generated key also gives cleanup jobs a predictable prefix without requiring the storage service to understand your business model.

Here is the small piece I keep close to the application boundary. It is deliberately storage-neutral: the point is to make a bad key impossible before an SDK or REST client sees it.

import os
import re
import time

import requests


SAFE_ID = re.compile(r"^[A-Za-z0-9_-]{1,64}$")


def put_avatar(tenant_id: str, user_id: str, content: bytes) -> str:
    if not SAFE_ID.fullmatch(tenant_id) or not SAFE_ID.fullmatch(user_id):
        raise ValueError("tenant_id and user_id must be safe identifiers")

    bucket = os.environ["INFRAI_STORAGE_BUCKET"]
    key = f"tenants/{tenant_id}/avatars/{user_id}.webp"
    base_url = os.environ["INFRAI_BASE_URL"].rstrip("/")
    url = f"{base_url}/storage/object/put/{bucket}/{key}"
    headers = {
        "Authorization": f"Bearer {os.environ['INFRAI_API_KEY']}",
        "Idempotency-Key": f"avatar-{tenant_id}-{user_id}",
        "Content-Type": "image/webp",
    }

    for attempt in range(4):
        response = requests.put(url, headers=headers, data=content, timeout=30)
        if response.status_code != 429:
            if not response.ok:
                raise RuntimeError(
                    f"avatar upload failed ({response.status_code}): {response.text}"
                )
            return key

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

    raise RuntimeError("avatar upload was rate limited after 4 attempts")


print(put_avatar("acme_support", "user_106", b"webp bytes"))
Enter fullscreen mode Exit fullscreen mode

The output is deterministic, but the object is still private. A deterministic name is not an authorization mechanism. Before returning a signed URL, the application must compare the authenticated tenant with avatar.tenant_id; the storage provider cannot infer that relationship from a path string.

Measure this choice before copying it: cross-tenant read tests, expired-link behavior, deletion completion, and the number of backend calls needed for upload and download. Those checks tell you more than a storage feature checklist.

Which cloud storage should a Node.js-based app use for user avatars?

The obvious candidates are Amazon S3, Cloudflare R2, Google Cloud Storage, and a unified backend surface such as Infrai. All can be evaluated against the same narrow workflow: a server-side Node.js integration, private objects, signed access, useful documentation, and predictable tenant scoping. The important difference is the integration boundary you want to operate.

Option Practical strength Boundary to verify
Amazon S3 Deep ecosystem, mature SDKs, and extensive examples for signed requests You still own tenant authorization, key policy, and lifecycle design
Cloudflare R2 S3-compatible access and a focused object-storage workflow Confirm that your chosen SDK path and signed URL behavior match your deployment
Google Cloud Storage Strong client-library support and signed URL documentation Validate identity setup and the service account permissions used by the backend
Infrai storage A plain REST contract can put storage beside other backend capabilities under one key; its discovery surface publishes schemas and runnable examples It does not provide public-read ACLs, object versioning or object lock, conditional If-Match writes, or self-service CORS configuration

The first three are sensible defaults when a team wants a dedicated object-storage product and already operates that cloud. Their SDKs reduce setup friction, and the documentation around signed access is easy to find. Infrai is a stronger fit when the team values breadth behind one consistent surface: adding a storage capability is another endpoint in the same backend contract instead of another integration to provision and reconcile. That convenience does not remove the application-level tenant check.

Infrai is not the right choice when public-read media, object lock, version recovery, browser-configured CORS, or automatic cross-region replication is a hard requirement. In those cases, choose the dedicated provider whose control matches the requirement; S3 is the more natural starting point for a team that needs a broad object-storage toolbox. The trade-off is clear: one consistent backend surface can shorten integration work, while a dedicated storage product exposes more storage-specific controls.

There is a useful distinction here. A provider can make signed URL generation easy while leaving the policy around those URLs entirely to you. That is normal. The service should not be expected to understand support tickets, user membership, or which agent is allowed to see an avatar.

Where does the simple approach stop working?

A private object store with expiring links is a good fit for avatars and ordinary user uploads. It is a poor fit for every storage problem.

Keep it private.

Public or permanently shared media needs a public-read policy and a stable public URL. The verified storage surface discussed here has no public or public-read ACL, and its public_url remains null, so it should not be selected as a static-site host or an image host that promises permanent direct links.

Accidental overwrites need a recovery story. There is no object versioning or WORM-style object lock, so an overwritten avatar is not recoverable through those controls. If the requirement is strict concurrent exclusion, the absence of an If-Match conditional write means the application needs a queue or database coordination. That is a design boundary, not a small SDK inconvenience.

Browser-direct uploads deserve their own check. Without an independently exposed CORS configuration route, a team cannot assume that it can configure browser origins from the storage API. A backend-mediated flow may be the cleaner choice for a support product, especially while the authorization rules are still changing.

There are operational edges too: lifecycle expiration is no shorter than one day, multipart fragments have no automatic cleanup rule, metadata is not server-searchable beyond prefix filtering, and there is no cross-region automatic replication or cross-cloud bulk migration tool. These constraints are tolerable for small profile objects. They become consequential for a regulated archive or a large migration program.

Finally, do not confuse a trial credit with durable storage capacity. Persistent writes can be restricted during a trial, so a test that succeeds only under temporary credits is not evidence that a production upload path is ready.

A decision rule I would ship

Pick Amazon S3 when the surrounding AWS ecosystem is already the main operating environment and the team wants the broadest mature object-storage toolbox. Pick R2 when an S3-compatible workflow and Cloudflare deployment fit the system. Pick Google Cloud Storage when Google identity and client libraries are already part of the backend.

Pick a unified REST surface when reducing integration count is more valuable than storage-specific controls, and the missing controls are outside the product's requirements. In that case, the consistent contract, public discovery metadata, and runnable examples can shorten prototyping for a Python or Node.js backend. Keep the claim narrow: it simplifies the integration surface; it does not replace tenant policy, retention design, or an incident recovery plan.

For the avatar case, my default is therefore private objects, server-created keys, backend authorization, and links that expire. The final choice should be made after four tests pass: a tenant cannot read another tenant's key, an expired link stops working, deletion reaches every copy the application owns, and a failed upload can be retried without creating an authorization hole.

That is the part worth carrying from notebook to production. The storage API is only one component of the contract.

References

Sources

Top comments (0)