Short answer: for a browser direct to storage upload from a Node.js SaaS frontend, use short-lived presigned PUT and GET URLs, but let the backend choose every tenant-scoped object key; compare S3-compatible options only after testing CORS, private delivery, and recovery requirements in both the US and EU.

For a fintech SaaS, the cheapest-looking upload path is irrelevant if one tenant can influence another tenant's key. The durable design is a small control plane in the Node.js backend and a large data path from the browser directly to storage. The backend authenticates the user, derives the tenant prefix, allows a narrow content type and size, and issues a short expiry. The browser never receives a storage credential.

This is the decision record I would use for product images and tenant-scoped exports. It favors presigned URLs, rejects permanent public objects, and treats CORS as a release gate rather than a dashboard checkbox someone can fix after launch.

What should a browser direct-to-storage upload for Node.js SaaS isolate?

Four invariants matter. First, the server owns the mapping from an authenticated tenant to an object key such as tenants/tnt_7f3/products/prd_204/hero.png; the client may suggest a filename, but it doesn't supply a tenant prefix. Second, a signed upload is short-lived and authorizes one intended operation. Third, reads are private and use presigned GET URLs. Fourth, storage metadata is not the authorization database: keep tenant, object key, owner, state, and retention policy in the application database.

Keys are policy.

That last distinction prevents a subtle failure. Suppose a browser successfully uploads bytes but the request that records the image is interrupted. The object exists, yet the product record does not. Reverse the order and the database can point at bytes that never arrived. Model this as a state machine (pending, uploaded, attached, expired) and make finalization idempotent. A periodic reconciler can inspect known pending keys and remove abandoned objects according to policy. Don't try to discover ownership later by searching arbitrary metadata, because the storage list operation only supports prefix filtering in the capability evaluated here.

The failure boundaries are equally concrete: authentication ends at the signing endpoint; authorization is bound into the key; browser compatibility ends at CORS; and durable business state ends in the database. Keep those boundaries visible in logs, but never log the signed query string. It is a temporary credential.

One more edge case: product images and exports have different disclosure rules. An image may be shared with many authorized viewers, while an export can contain financial data and should normally be single-tenant, short-lived, and auditable. They can share a bucket and naming convention, but they should not share expiry defaults or application permissions.

Compare the control planes, not just storage prices

Provider prices and egress rules change, so I won't crown a universal cheapest option. Measure the actual mix: writes, reads, retained bytes, egress, operations, and the engineering cost of the browser path. The architectural differences below survive price-page changes longer than a per-gigabyte snapshot does.

Option Where it fits Tenant-isolation control Main reason to choose something else
AWS S3 Teams that want a broad, direct object-storage control plane Backend-generated prefixes and presigned requests More provider-specific IAM and billing surface to own
Cloudflare R2 S3-compatible object workflows, especially when delivery architecture already sits near Cloudflare The application still has to bind identity to the signed key Validate regional, compliance, and public-delivery requirements for the exact deployment
Supabase Storage Applications already using Supabase authorization and data services Storage policies can align with the application's user model It couples the upload decision to that broader application stack
UploadThing Teams prioritizing a managed browser-upload developer workflow The backend still decides who may request an upload Less attractive when direct object-store control is the primary requirement
Infrai A backend that benefits from one key and one bill across multiple backend services, plus a plain REST interface without another SDK Server-selected tenant prefixes with short-lived presigned PUT/GET flows Not suitable for permanent public links, strict object immutability, or projects that must self-manage every browser CORS rule

The fifth option has a real operational advantage for a small platform team: storage can share credentials and billing with other backend capabilities instead of adding another dashboard and invoice. Its consistent REST shape also keeps the signing adapter language-neutral. The catch is meaningful here. Public/public-read ACL is unavailable and public_url remains null; object versioning, Object Lock/WORM, conditional If-Match writes, automatic cross-region replication, and cross-cloud bulk migration are not part of this storage capability. Vendor coverage includes R2, S3, OSS, and COS, but not GCS or B2.

Stick with AWS S3 when versioning or Object Lock is a hard fintech control. Prefer Supabase Storage when its policy model is already your authorization center. UploadThing is a reasonable choice when managed upload ergonomics matter more than owning the object-store control plane. R2 deserves a proof against the precise US/EU placement and delivery requirements rather than an assumption based on API compatibility.

No table can settle compliance. Data residency, deletion evidence, processor terms, and access logging need review against the account and region you will actually buy.

Put the tenant boundary in the signing endpoint

The following Python service demonstrates the critical control-plane path. It accepts a product identifier and filename, derives the tenant from an already verified request header standing in for middleware, constructs the key on the server, and calls the one verified presign route. In production, replace that header boundary with your session or token verifier; do not accept a raw tenant ID from an untrusted browser.

import json import os import random import time from urllib import error, parse, request from flask import Flask, jsonify, request as flask_request app = Flask(__name__) API_BASE = os.environ["STORAGE_API_BASE"].rstrip("/") BUCKET = os.environ["UPLOAD_BUCKET"] API_KEY = os.environ["INFRAI_API_KEY"] def safe_segment(value: str) -> str: allowed = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_" if not value or any(char not in allowed for char in value): raise ValueError("invalid identifier") return value def presign_upload(bucket: str, key: str) -> dict: encoded_bucket = parse.quote(bucket, safe="") encoded_key = parse.quote(key, safe="/") url = f"{API_BASE}/storage/object/presign/{encoded_bucket}/{encoded_key}" headers = { "Authorization": f"Bearer {API_KEY}", "Accept": "application/json", } for attempt in range(4): api_request = request.Request(url, headers=headers, method="POST") try: with request.urlopen(api_request, timeout=10) as response: return json.load(response) except error.HTTPError as exc: body = exc.read().decode("utf-8", errors="replace") if exc.code != 429 or attempt == 3: raise RuntimeError(f"presign rejected with HTTP {exc.code}: {body}") from exc retry_after = exc.headers.get("Retry-After") delay = float(retry_after) if retry_after else (2**attempt) + random.random() time.sleep(delay) raise RuntimeError("presign retry limit reached") @app.post("/product-image-uploads") def create_product_image_upload(): tenant_id = safe_segment(flask_request.headers["X-Verified-Tenant-Id"]) payload = flask_request.get_json() product_id = safe_segment(payload["product_id"]) filename = safe_segment(payload["filename"]) key = f"tenants/{tenant_id}/products/{product_id}/{filename}" return jsonify({"object_key": key, "presign": presign_upload(BUCKET, key)}) 

The code deliberately returns the documented presign response without guessing at response-field names. The browser uses the returned PUT contract, then calls an application finalization endpoint; that endpoint should verify the pending record and bind the object to the product. A 429 receives bounded exponential backoff and honors Retry-After, while every other HTTP error is surfaced with its response body. There is no tight retry loop.

Do not make the generated URL reusable as a product URL. Issue a presigned GET when an authorized user needs the image or export, and keep the stable application URL at your own domain.

CORS and multipart uploads are release tests

CORS is part of the data path. A successful server-side presign does not prove that a browser origin can send the resulting PUT: the preflight still has to allow the production origin, method, and headers. Test from the real frontend origins in the US and EU environments, including the exact Content-Type header the browser will send. Avoid * for an authenticated fintech surface.

For the capability assessed here, browser CORS rules are not a self-service API control, even though the bucket model contains cors_rules. That makes it weaker than options where the team can configure the rule directly. If independent CORS changes are a deployment requirement, choose a provider whose control plane exposes them and verify the preflight before launch.

Large exports add another lifecycle. Multipart upload is available, but abandoned parts are not cleaned automatically; every upload must reach explicit completion or abort. Lifecycle expiry has a minimum of one day, not hours, so it is not a substitute for prompt multipart cleanup. A queue-backed sweeper should own stale upload IDs, and its alert should distinguish an application timeout from a user cancellation.

Be strict here.

Trial credits cannot pay for persistent writes, so a browser upload test needs a paid-capable account before it becomes a release signal. I'm not sure any vendor comparison remains accurate without testing the precise account, origin, region, and headers; a recorded preflight plus upload/download smoke test resolves that uncertainty.

Record the rejected public-link design

The rejected design is to upload an image once, mark it public, and store a permanent object URL in the product row. It is simple and can be valid for a public image host or a static site, but it is the wrong default for tenant-scoped fintech assets. This capability cannot provide that permanent public/public-read object, and an export should not inherit public reachability merely because images and exports share storage infrastructure.

There is another rejection: storage alone is not a financial immutability system. Without versioning, Object Lock/WORM, or conditional writes, accidental overwrite recovery and strict concurrent exclusion require an external design. Coordinate writers through a database or queue, use unique immutable keys, and select AWS S3 or another verified system when retention-enforced immutability is mandatory. That is a capability boundary, not an upload inconvenience.

The final decision is conditional: use the unified REST option when private presigned transfer, server-owned keys, explicit multipart cleanup, and centrally managed CORS match the system. Otherwise choose the competitor whose control plane satisfies the missing invariant. Tenant isolation wins the tie.

References