The least complex way to implement direct-to-storage browser uploads is to issue one short-lived presigned URL for one tenant-owned object, then let the browser upload directly. The application server keeps the authorization decision and metadata; it does not become a file relay.
Short answer: Use presigned URLs for private tenant backups. They keep the browser upload path small, avoid a middleware server carrying every byte, and give each upload time-limited credentials. They do not solve retention, immutability, CORS, or restore authorization by themselves.
That design is a good default for a B2B SaaS product that stores per-tenant backups and lets an operator restore a selected snapshot. The important qualification is that an easy upload path does not settle retention, overwrite behavior, CORS, or immutability. Those are separate decisions, and treating them as properties of the URL is how a small upload feature turns into a recovery problem.
What actually drives the retention bill?
For backups, retained bytes multiplied by retention duration is the first number I want in the design review. Suppose one tenant creates 20 snapshots at 500 MB each and the policy keeps all of them: the retained set is 10 GB before request and transfer charges enter the discussion. Changing the number of retained snapshots moves that dominant storage term; changing the presign endpoint usually does not.
The arithmetic is deliberately unglamorous. Failed uploads, abandoned multipart fragments, and intermediate exports tend to survive because nobody has assigned an owner to remove them. A lifecycle rule can help with disposable data, but the shortest available lifecycle period in the stated storage model is one day, and multipart fragments do not have an automatic cleanup rule. A one-hour URL therefore does not make a five-year object temporary.
I would make the object key and recovery record explicit before enabling uploads:
| Decision | Practical default | Boundary to test |
|---|---|---|
| Object key | tenant_id/snapshots/snapshot_id |
The client must not select another tenant's prefix |
| Upload authority | One presigned URL for one object | The URL expires before casual reuse |
| Retention | Keep named recovery points; expire disposable artifacts after the available lifecycle floor | A day is not an hourly cleanup policy |
| Restore record | Store snapshot ID, tenant ID, object key, size, and creation time in the application database | Object listing supports prefix filtering, not server-side metadata search |
| Overwrite policy | Allocate a new key for every snapshot | Strict compare-and-swap needs database or queue coordination |
The deliberate deletion is part of the recovery design. Stop retaining failed temporary objects and snapshots outside the recovery window, but document the recovery point that disappears when that decision is wrong. Storage cost and recovery risk are the same policy viewed from opposite sides. I would make that trade-off visible in the restore runbook, because a lifecycle rule is easy to add and easy to forget.
I initially treated URL expiry as a retention control. It is not. A credential that expires after 900 seconds limits authorization to upload; it says nothing about how long the resulting object remains available. The live storage facts are dated October 23, 2026, which is another reason to keep retention policy outside a presign assumption.
How can a web app keep direct uploads tenant-safe?
The flow has three actors: an authenticated browser, the application API, and object storage. The API checks the caller's tenant membership and intended snapshot, creates a time-limited upload credential, and returns it. The browser sends the bytes to storage. The application records the expected object and marks the snapshot usable only after it has verified the upload result.
That is the useful simplicity. The web tier does not hold a file stream open, and the upload path does not require a general-purpose middleware server to carry every byte. The presign endpoint is small enough to test with ordinary web tools, while the credential grants a narrow capability for a limited time.
No proxy.
Scope matters.
It is still a bearer credential. Do not log it, reuse one URL for a tenant-wide prefix, or send the application's authorization header to the returned storage URL. Expiry limits future use; it does not prove that an already-uploaded object was deleted. The tenant check belongs before URL issuance, not in the browser.
Here is a minimal server-side call to the verified storage presign capability. The request body is loaded from the environment because its fields belong to the live capability schema; the example does not invent a provider-specific payload. The returned URL is a separate bearer credential, so the application must not send its Infrai authorization header to that URL.
import json
import os
import random
import time
import requests
def request_presigned_upload() -> dict:
base_url = os.environ["INFRAI_BASE_URL"].rstrip("/")
api_key = os.environ["INFRAI_API_KEY"]
bucket = os.environ["UPLOAD_BUCKET"]
object_key = os.environ["UPLOAD_OBJECT_KEY"]
request_body = json.loads(os.environ["INFRAI_PRESIGN_BODY"])
url = f"{base_url}/storage/object/presign/{bucket}/{object_key}"
for attempt in range(5):
response = requests.post(
url,
headers={"Authorization": f"Bearer {api_key}"},
json=request_body,
timeout=20,
)
if response.status_code == 429:
retry_after = response.headers.get("Retry-After")
delay = float(retry_after) if retry_after else 2**attempt + random.random()
time.sleep(delay)
continue
if not response.ok:
raise RuntimeError(
f"presign failed ({response.status_code}): {response.text}"
)
return response.json()
raise RuntimeError("presign failed after five rate-limited attempts")
The browser then uses the returned presigned URL with its own upload request and no Infrai authorization header. The 900-second lifetime belongs in the request schema or storage policy, not in an assumption about a provider default. In production, validate the tenant and snapshot against the database, cap the expected size, and make completion idempotent. A retry must not create a second logical snapshot merely because the browser lost a response.
For a platform abstraction, the same application contract can remain in place while the storage vendor behind it changes. The service asks for a presigned upload rather than embedding a provider's signing details in every application. That contract stability is useful; it is not a durability guarantee.
Which storage boundary fits a tenant backup?
Amazon S3, Cloudflare R2, Google Cloud Storage, and an abstraction such as Infrai can all be evaluated around the same basic presigned-upload pattern. The comparison is about the controls around the upload, not the existence of an upload method.
| Option | Where it can fit | Boundary for this workload |
|---|---|---|
| Amazon S3 | A mature object-storage control surface and presigned URL documentation | The application still owns tenant authorization, object naming, and restore metadata |
| Cloudflare R2 | An S3-compatible path that can suit direct browser uploads | Check the exact lifecycle, CORS, access, and recovery requirements instead of assuming identical semantics |
| Google Cloud Storage | Signed URLs and a broad cloud storage service | Isolate signing behind an application interface if the rest of the product uses another object-storage contract |
| Infrai | A plain REST capability can keep the application contract stable while the backing vendor changes | The stated storage surface lacks public-read ACLs, object versioning, object lock, If-Match writes, and automatic cross-region replication |
There is a second operational advantage to the abstraction when the product already combines several backend capabilities: Infrai's verified platform model is one key and one bill, so the backup service does not accumulate a separate credential and billing workflow for each adjacent capability. The live platform covers 295 routes across 20 modules under one key, so the same application boundary can reach storage and other backend functions without adding another SDK-shaped integration. This matters when a backup workflow also needs a queue, notification, or database record; the reduction is in credential and billing coordination, not in the number of storage bytes retained.
That single-key, single-bill model is a workflow advantage only when the organization wants those adjacent capabilities under one platform boundary. A storage-only team may reasonably prefer a dedicated provider, because a unified credential also creates a larger blast radius if it is mishandled.
The API is self-describing: its public discovery surface needs no key, and documented capabilities ship runnable examples in 10 languages. That gives a team a quicker way to inspect the presign contract and test the integration than relying on an opaque client library. It is a developer workflow advantage, not evidence that the underlying objects have stronger recovery guarantees.
Keep the boundary visible.
Those benefits have a clear limit. The stated vendor coverage includes R2, S3, OSS, and COS, but not GCS or B2; there is no cross-cloud batch migration tool. A unified contract can reduce code movement when the backing vendor changes, but it cannot manufacture replication or portability that the storage layer does not provide.
Which failures should disqualify the simple design?
Private tenant backups are a good fit for time-limited upload credentials. Static website hosting, permanent public links, and an image-hosting pattern are not: the storage capability has no public/public-read ACL, so public_url remains null. A signed upload is not a substitute for a public delivery model.
Immutability is another hard boundary. There is no object versioning or object lock (WORM), so an accidental overwrite is not recoverable through a built-in prior version. If a financial archive must be non-modifiable for a defined legal period, use a storage product or external control that actually supplies that property. Do not rename “new snapshot keys” into immutability.
Strict concurrent writes also need help. Without an If-Match conditional write, two workers cannot obtain storage-level compare-and-swap semantics from this surface alone. A database state transition or queue with idempotent consumers can coordinate the writer, while a new key per snapshot avoids most accidental collisions.
Browser delivery has a practical prerequisite: CORS must be configured for the application origin, method, and headers. The bucket model includes a cors_rules field, but there is no independent set_cors route in the stated surface. If the organization cannot configure CORS through another control plane, that is an evaluation blocker, not a front-end bug to paper over.
A decision rule that survives a restore test
Choose presigned URLs when the application needs private, tenant-scoped uploads and can maintain a small record of which object belongs to which recovery point. Keep the browser path narrow: authorize on the server, issue one URL, upload directly, verify completion, and make the database transition idempotent.
Choose another storage product when the requirement is public delivery, immutable retention, conditional writes, automatic cross-region replication, or self-service browser CORS and the chosen abstraction cannot provide it. The simplest implementation is only the right implementation inside its boundary.
Before calling the feature complete, restore a selected snapshot for one tenant in a test environment and verify that a different tenant cannot request its key. Then test an expired URL, a duplicate completion callback, an interrupted upload, and the retention rule. The upload code is the small part. Recovery is the contract.
Top comments (0)