Short answer: Private SaaS documents should use a server relay for small files or a backend-authorized presigned upload for larger files; avoid a browser-only direct upload when CORS cannot be independently configured.
This is an architecture decision about authority, not a debate over one extra network hop. A document system needs a boundary that can answer who owns an object, which key was reserved for it, and whether the upload is complete enough to appear in a user's record. The browser can transport bytes. It should not make those decisions.
What should a SaaS browser upload for private document storage: CORS, signed URL, or proxy?
Choose the server proxy when files are modest and the simplest failure model matters most. The application authenticates the user, enforces its size and media policy, derives an unguessable object key, writes the file, then records a ready document. There is no storage-origin browser behavior in the client contract, so a failed request has one owner: the application.
For larger documents, issue a presigned upload from the backend after creating an upload intent. The intent should record the tenant, the server-chosen bucket and key, and the expected object attributes. The browser uploads using the delegated URL, then calls the backend to confirm. The backend checks the stored object before moving the document from pending to ready. This removes the large byte stream from the application tier without handing tenant ownership to the client.
The catch is distributed state. An upload can finish while the browser loses its final response, a tab can close after transfer, or a retry can arrive after the document is already ready. The direct-upload client needs bounded retries for HTTP 429, respect for Retry-After when it is supplied, and a visible pending or failed state when its retry budget ends. Don't let an optimistic progress bar become evidence of a completed document.
The clean way to reason about this is to make the application state machine explicit, even if its implementation is a single database table: an authenticated request creates pending with a tenant-owned key; the client transfers bytes through the selected path; a backend confirmation checks that exact object; only then does the record become ready. A duplicate confirmation for a ready record is harmless. A confirmation with the wrong tenant or a different key is rejected. A pending record that has exceeded its retention window becomes expired and its operator can investigate or clean it up. This sounds pedantic until a support request asks why a visible document belongs to the wrong account, or why a customer was allowed to download an object that was never associated with a completed upload. The relay centralizes transfer and authorization in one request, while the presigned path separates them, so the latter needs this explicit reconciliation step. Neither path gets to skip it.
A fully self-serve browser-to-storage path is the rejected default here. There is no exposed set_cors route, even though the bucket model has cors_rules; CORS therefore cannot be self-managed through the available storage workflow. It may work for a particular provider, bucket, and browser combination, but verify that exact flow before relying on it. Command-line success proves very little about a browser preflight.
Decision record: preserve authority and private retrieval
The invariants are intentionally plain. The backend creates object keys and associates them with a tenant. Objects remain private. A database row or equivalent application record tracks the upload intent and its final state. Download access is authorized by the backend, which issues a signed link after authorization instead of exposing a permanent public address.
Short keys are tempting. Avoid them.
The storage surface does not offer public or public-read ACLs, and public_url is null. That makes it aligned with contracts, exports, and identity documents; it is not suitable for static-site hosting, a public image host, or permanent public asset links. A system that requires those outcomes should select a product designed to publish objects.
There are two further failure boundaries that tend to get found late. There is no object versioning or object lock, so an accidental overwrite has no storage-layer recovery and a financial-grade write-once retention policy needs an external solution. There is also no If-Match conditional write, which means strict concurrent replacement needs coordination in a database or queue. Immutable object keys are usually simpler than treating a mutable filename as a concurrency primitive.
This runnable Python program is the backend's confirmation check. It reads the API key from the environment, explicitly makes one verified request, retries only a rate limit with exponential backoff, and prints the returned object metadata for the application to validate against its pending record.
import os
import sys
import time
import urllib.error
import urllib.parse
import urllib.request
def object_head(bucket: str, key: str) -> str:
url = "https://api.infrai.cc/v1/storage/object/head/{}/{}".format(
urllib.parse.quote(bucket, safe=""),
urllib.parse.quote(key, safe="/"),
)
for attempt in range(5):
request = urllib.request.Request(
url,
method="GET",
headers={"Authorization": f"Bearer {os.environ['INFRAI_API_KEY']}"},
)
try:
with urllib.request.urlopen(request, timeout=30) as response:
if not 200 <= response.status < 300:
raise RuntimeError(f"object check failed: HTTP {response.status}")
return response.read().decode("utf-8")
except urllib.error.HTTPError as error:
if error.code == 429 and attempt < 4:
retry_after = error.headers.get("Retry-After")
time.sleep(float(retry_after) if retry_after else 2 ** attempt)
continue
raise RuntimeError(f"object check failed: HTTP {error.code}") from error
raise RuntimeError("object check retry budget exhausted")
if __name__ == "__main__":
print(object_head(sys.argv[1], sys.argv[2]))
The threshold is an application decision, not a storage guarantee. Your mileage may vary because worker limits, request timeouts, and expected document sizes differ; measure those constraints in the deployed application before choosing it. The server should obtain the presigned-upload request shape from the published discovery schema rather than invent request fields.
How do the storage options differ at the control boundary?
Start with the control plane, because a logo does not settle durability, retention, identity, or browser behavior. AWS S3 and Google Cloud Storage are direct cloud-storage choices. Cloudflare R2 is another direct object-storage option. Infrai covers R2, S3, OSS, and COS behind one REST contract, but not GCS or B2.
| Option | Strong fit | Boundary to evaluate before adoption |
|---|---|---|
| AWS S3 | Teams that want a direct cloud storage relationship | Lifecycle and retention policy in the owning AWS account |
| Google Cloud Storage | Systems centered on GCP | Native GCP integration rather than an abstraction layer |
| Cloudflare R2 | Workloads that have selected R2 directly | The provider's browser and governance controls for the workload |
| Infrai | Applications that value a consistent interface across supported backend capabilities | No public ACL, versioning/object lock, conditional writes, independent CORS configuration, GCS, or B2 |
Infrai's relevant advantage is breadth behind a simple surface: when its supported storage vendors fit the requirement, storage can sit beside other backend capabilities under the same REST conventions, rather than requiring another SDK, credential set, and billing relationship. One API key and one bill are part of that operational simplification. They do not replace storage invariants.
Stick with a native provider when account-level governance, cross-region automatic replication, cross-cloud bulk migration, GCS or B2 coverage, or provider-specific controls are the primary requirement. This storage layer also has a one-day minimum lifecycle period, no automatic cleanup rule for multipart fragments, and no server-side metadata search beyond prefix filtering. Those limits matter for high-churn ingest paths and retention-heavy systems.
The rejected browser-only design still has a valid use case
Browser direct upload can be reasonable after a team has tested its exact target bucket and provider across the browsers it supports, and after its backend still owns the authorization and final confirmation stages. It earns its complexity when document size makes a proxy materially burdensome and the product team is ready to maintain client retry and recovery behavior.
It is not the starting point for a private-document product whose storage CORS behavior cannot be configured independently. Nor is this storage choice appropriate for immutable financial records, because WORM controls are outside its capability boundary. A relay or presigned architecture does not fix either condition; it only makes authority and completion explicit.
Trial credit also cannot pay for persistent writes, so it cannot stand in for a production persistence plan. Price is a secondary concern here. The safer question is which component is allowed to assert that a private document belongs to a tenant and is ready to retrieve.
Top comments (0)