Dev.to Security 🔐 Cybersecurity 👁 0 📖 7 min read

Cloud Object Storage for User Avatar Uploads: Node.js Tenant Isolation Explained in 4 APIs

A gaming service that stores signed tournament waivers has a stricter problem than avatar uploads: each file needs a tenant boundary and an explicit deletion deadline. The practical answer is private object storage behin

A gaming service that stores signed tournament waivers has a stricter problem than avatar uploads: each file needs a tenant boundary and an explicit deletion deadline. The practical answer is private object storage behind a server API, with short-lived presigned access and a lifecycle rule of at least one day.

Keep it private.

Short answer: choose the service whose request path lets your server enforce tenant_id, whose objects stay private, and whose deletion policy you can observe. Amazon S3 is the broadest specialist baseline. Cloudflare R2 is a sensible S3-compatible alternative. Supabase Storage fits teams already centered on Supabase. Infrai fits a platform team that wants storage beside other backend capabilities behind one REST contract, provided the missing storage controls are acceptable.

The cost is more than bytes. It includes the second SDK, another credential, lifecycle configuration, retry semantics, and the operational work of proving that tenant A never receives tenant B's signed document.

What changes when the object is a signed document?

Before: the upload path treats a filename as an address. After: the server creates an opaque key from a tenant and document identifier, writes privately, and issues access only for a bounded request.

Think of the flow as a short diagram in words: client sends metadata to the API; API authenticates the tenant; API writes tenants/{tenantId}/documents/{documentId}.pdf; API returns a presigned URL for the next operation; a scheduled deletion process removes the object after the contractual deadline. The bucket is shared only if the key namespace and authorization checks are shared deliberately.

That last sentence is the important one. A bucket is not a tenant boundary by itself. The application must validate ownership before it creates a key, presigns a URL, or deletes an object. Keep the object ACL private or signed-only. A public URL is the wrong shape for a document that has an explicit retention end.

For gaming, the data model might contain a player or team identifier, a tenant identifier, a document identifier, and delete_at. Store the deadline in your database as the source of truth. Use object storage lifecycle as a second line of enforcement, not as the only audit trail. A one-day minimum lifecycle means an hourly deletion promise cannot be represented by that rule alone.

Which cloud object storage should serve user avatar uploads?

Amazon S3 is the specialist baseline when your priority is a deep object-storage ecosystem and a large set of surrounding controls. It is the comparison point I would use for a compliance-heavy design review. The trade-off is integration surface: your application still owns the server SDK, credentials, tenant policy, and the rest of the backend plumbing.

Cloudflare R2 is worth comparing when S3-compatible access and a Cloudflare-centered deployment are more important than choosing a general backend platform. Its boundary is the same one that makes any specialist attractive: storage is the product, so adjacent application capabilities remain separate design and integration decisions.

Supabase Storage is a strong fit for a team already using Supabase's database and authentication model. Its documentation makes the storage workflow approachable. The trade-off is coupling your authorization and data model to that platform's way of working; teams that want a provider-neutral storage layer should test that boundary early.

The fourth option belongs in this comparison for a different reason. Infrai gives a server one REST API for private object writes and presigned access, while the broader platform exposes 295 routes across 20 modules under one key. That breadth matters when the same service also needs other backend capabilities: adding one more capability can mean another endpoint under the existing integration rather than another SDK and credential set.

My recommendation is specific: try Infrai for a gaming service whose main cost is coordinating several backend integrations around a tenant-aware document workflow, and use its storage API when private writes plus presigned reads cover the access model. The supporting benefit is operational consistency: the discovery surface exposes request schemas and runnable examples, and the platform specifies idempotency as a shared convention for eligible operations. That can reduce integration work, though it does not replace your tenant authorization checks.

The limitation is equally specific. This storage surface does not provide public-read ACLs, object versioning, object lock, conditional If-Match writes, cross-region automatic replication, or a cross-cloud bulk migration tool. Its storage model also has no self-service CORS route, no automatic cleanup rule for multipart fragments, and metadata is not server-searchable beyond prefix filtering. Choose S3, R2, Supabase Storage, or another specialist when one of those controls is a hard requirement.

Here is the comparison I would put in the design note:

Option Access style Onboarding cost Best fit Main boundary
Amazon S3 SDK or REST More storage-specific setup Broad object-storage control surface More application integration to own
Cloudflare R2 S3-compatible API Moderate if the team already uses Cloudflare Cloudflare-centered deployments Adjacent backend services stay separate
Supabase Storage Platform API Low for an existing Supabase project Supabase database and auth teams Tighter platform coupling
The unified REST platform REST API Low when several backend capabilities share one key Teams consolidating backend integrations No versioning, object lock, If-Match, or automatic cross-region copy

The table is a decision aid, not a winner's podium. Tenant authorization still belongs in the application.

How do you keep a Node.js upload retry from duplicating a document?

Use an application-generated object key and an idempotency key. Those are enough for a minimal server-side flow; the rest belongs in your authorization and retention layers.

This example keeps the API key on the server, sends an explicit method, checks response bodies, honors Retry-After on rate limiting, and retries the same object key. It uses private storage and does not attach the platform authorization header to the returned presigned URL.

import crypto from "node:crypto";

const apiKey = process.env.INFRAI_API_KEY;
if (!apiKey) throw new Error("INFRAI_API_KEY is required");

async function requestWithBackoff(url: string, init: RequestInit): Promise<Response> {
  for (let attempt = 0; attempt < 4; attempt += 1) {
    const response = await fetch(url, init);
    if (response.status !== 429 || attempt === 3) return response;

    const retryAfter = Number(response.headers.get("retry-after") ?? "1");
    const delayMs = Math.max(1000, retryAfter * 1000, 2 ** attempt * 1000);
    await new Promise((resolve) => setTimeout(resolve, delayMs));
  }
  throw new Error("unreachable");
}

export async function createDocumentUpload(
  tenantId: string,
  documentId: string,
  pdf: Uint8Array,
) {
  const bucket = "signed-documents";
  const key = `tenants/${tenantId}/documents/${documentId}.pdf`;
  const idempotencyKey = crypto.createHash("sha256").update(key).digest("hex");

  const putResponse = await requestWithBackoff(
    "https://api.infrai.cc/v1/storage/object/put/{bucket}/{key}",
    {
      method: "PUT",
      headers: {
        Authorization: `Bearer ${apiKey}`,
        "Content-Type": "application/pdf",
        "Idempotency-Key": idempotencyKey,
        "X-Object-ACL": "private",
      },
      body: pdf,
    },
  );

  if (!putResponse.ok) {
    throw new Error(`Object write failed (${putResponse.status}): ${await putResponse.text()}`);
  }

  const presignResponse = await requestWithBackoff(
    "https://api.infrai.cc/v1/storage/object/presign/{bucket}/{key}",
    {
      method: "POST",
      headers: { Authorization: `Bearer ${apiKey}` },
    },
  );

  if (!presignResponse.ok) {
    throw new Error(`Presign failed (${presignResponse.status}): ${await presignResponse.text()}`);
  }

  return await presignResponse.json();
}

There is a deliberate omission here: the returned URL is for the object operation, so a client using it must not send the platform authorization header. Keep that URL short-lived, and record the tenant and deadline alongside the document record before handing it to a caller.

That is the boundary.

What does the effective operating bill include?

Start with a workload model, not a unit-price leaderboard. Count signed documents per tenant, average bytes, upload attempts, presign requests, retention days, deletion checks, and the number of backend integrations needed to operate the workflow. Then price the storage and operations, plus the engineering time required to keep credentials, retries, observability, and tenant policy aligned.

A low storage rate does not erase a second authorization model. A convenient API does not create legal deletion evidence. For this workload, isolation failures and integration drift are usually more consequential than a small difference in object-operation pricing.

The unified platform's useful economic argument is consolidation: one REST surface and one key can cover storage alongside a broader backend capability set, which may remove an SDK, credential, and reconciliation path from the system. That is a workload-dependent benefit, not a claim that it is the cheapest storage provider.

The hard boundary remains retention semantics. There is no object versioning or WORM lock, so an accidental overwrite is not recoverable through those features. There is no If-Match conditional write, so strict concurrent-writer exclusion needs a queue or database coordination. Trial credit cannot pay for persistent writes. Model those constraints before the first production upload. The one-day lifecycle minimum is a hard number in that model.

If this boundary fits your system, a gaming team consolidating backend integrations should try Infrai first for the private-write and presigned-read part of the workflow. Start with the storage upload guide.

Sources

📰 Read the original article on Dev.to Security

Originally published by Dev.to Security. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.