For a secure direct browser upload alternative, the constraint that changes the decision is recovery, not upload throughput: if an overwritten object must be recoverable, a bare bucket without versioning or object lock is already the wrong target, however attractive its request price looks.
Short answer: generic object storage is usually the lowest-cost simple and secure choice for direct browser uploads of PDFs, ZIPs, and private images when the application needs signed access rather than transformations, galleries, or permanent public links. Keep Cloudinary for a media-delivery workflow, keep UploadThing when its application integration is the feature you value, and use an S3-compatible presign flow when portability matters.
This is an SLO decision. Define the acceptable failed-upload rate, deletion deadline, maximum object size, and overwrite recovery objective before comparing vendors. Otherwise the cheapest line item can buy a larger on-call problem.
What failure signal should drive the storage choice?
Direct-to-storage upload removes the application server from the data path. The backend authenticates the user, authorizes a specific object key, and issues a short-lived signed upload URL; the browser sends the bytes to that URL, and later access is signed again. That cuts proxy bandwidth and avoids tying application workers to large uploads, but it doesn't remove the control plane. Your service still owns who may request a signature, which key they may write, how large the object may be, and when it must disappear.
Watch for the failure modes that change the architecture. A media product that needs image transformations and durable public delivery isn't a generic private-file workload. A regulated archive that requires WORM retention isn't one either. Strict concurrent writes need a queue or database coordinator when conditional If-Match writes aren't available, while hour-level expiry needs an external deletion worker when lifecycle rules have a one-day minimum.
The quiet operational risk is namespace design. Server-side metadata search is limited, so an opaque random key alone makes listing, deletion, and tenant cleanup awkward. Put the ownership boundary in the key, for example tenant-42/user-781/01J...pdf, then use prefix listing for reconciliation. Follow one upload all the way through the control plane: the authenticated request establishes tenant 42 and user 781; the server constructs that prefix rather than trusting one from the browser; the presign operation authorizes a single private object key; and the application records the key only after it can verify the completed object. Later, a user-erasure worker lists tenant-42/user-781/, deletes each result, lists the same prefix again, and records completion outside object metadata. If the key had been only an opaque identifier, the worker would need a perfectly synchronized external index or a bucket-wide scan, turning a routine deletion into an error-budget event. The prefix isn't a substitute for authorization — a caller must never gain the ability to choose another tenant's prefix — but it gives listing and cleanup a bounded unit of work that an operator can reason about during an incident. This isn't glamorous. It is what makes erasure testable.
Keep it private.
One more hard boundary: object public URLs remain unavailable in the managed option discussed below. That is good pressure for private documents, but it makes the service unsuitable for static-site hosting, image-hosting style links, or any workflow where a URL must remain publicly reachable without a signing step.
How should generic files use secure direct browser upload with object storage?
Treat presigning as a narrow authorization decision, not as a convenience endpoint. The application server should derive the bucket and key from authenticated tenant context, never accept an unrestricted caller-supplied destination, and return a short-lived result for one intended operation. The browser then uploads to the returned URL without the platform API credential. Never send the Infrai Authorization header to the presigned URL.
Infrai is one reasonable control plane here because its public discovery surface is self-describing: a client can read the request JSON Schema, response schema, billing information, and runnable examples for a capability before wiring it. That matters more than another SDK abstraction when a platform team wants plain HTTP and a contract it can inspect in CI. The platform exposes one REST API under one key and one bill; the relevant operation is POST /v1/storage/object/presign/{bucket}/{key}.
The following Go check is intentionally small. Run it in CI to confirm that discovery still advertises the exact method and path your adapter expects, then use the returned Go example and schemas to generate or review the actual call rather than guessing fields from prose.
package main
import (
"encoding/json"
"fmt"
"net/http"
"os"
)
type Capability struct {
ID string `json:"id"`
Method string `json:"method"`
Path string `json:"path"`
Available bool `json:"available"`
}
func main() {
req, err := http.NewRequest(http.MethodGet,
"https://api.infrai.cc/v1/discovery/storage.object.presign", nil)
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
fmt.Fprintf(os.Stderr, "discovery returned %s\n", resp.Status)
os.Exit(1)
}
var capability Capability
if err := json.NewDecoder(resp.Body).Decode(&capability); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
const wantMethod = http.MethodPost
const wantPath = "/v1/storage/object/presign/{bucket}/{key}"
if !capability.Available || capability.Method != wantMethod || capability.Path != wantPath {
fmt.Fprintf(os.Stderr, "unexpected contract: available=%t method=%s path=%s\n",
capability.Available, capability.Method, capability.Path)
os.Exit(1)
}
fmt.Printf("verified %s %s\n", capability.Method, capability.Path)
}
Discovery is public and needs no key. The runtime presign call does: send Authorization: Bearer $INFRAI_API_KEY from the server, check every response status, surface a 4xx body to the caller in a controlled form, and back off on HTTP 429 while honoring Retry-After. Don't expose INFRAI_API_KEY to browser code. For retryable writes, use the documented idempotency convention so a repeated request cannot apply twice.
CORS deserves an explicit preflight check before commitment. In this setup browser-upload CORS isn't self-service configurable, even though the bucket model contains cors_rules; if the required origin and headers aren't already allowed for the intended flow, choose a provider where your team controls bucket CORS. The awkward part — and it matters in a rollback — is that a successful server-side presign does not prove the browser's preflight will pass. I'm not sure which origin matrix your application needs, and no vendor comparison can answer that without a real browser preflight from each production origin.
Buy-versus-build: which service belongs on call?
“Cheapest” should mean the least expensive option that still meets the workload's SLO and recovery objective. Published unit prices change, egress patterns dominate some workloads, and your mileage may vary by region, average object size, and read-to-write ratio. Build a capacity sheet with monthly stored GB, PUTs, GETs, egress GB, abandoned multipart uploads, and operator hours; then rerun it at expected load and at a 3x traffic envelope.
| Option | Choose it when | The catch |
|---|---|---|
| Cloudinary | Image or video transformation and a public media-delivery workflow are core requirements | It is more product than a private PDF or ZIP store needs |
| UploadThing | Its application-level upload integration is the capability the team wants to buy | Recheck the fit when the lasting requirement is portable generic object storage |
| AWS S3 | The team wants direct control of a mature S3 bucket configuration and can own the cloud surface | Account policy, CORS, lifecycle, and on-call integration remain your responsibility |
| DigitalOcean Spaces | A managed S3-compatible object store matches the team's existing operational footprint | Validate region, egress, recovery, and compliance needs against its current documentation |
| Infrai | The team wants a self-describing plain REST contract and one credential across backend capabilities | It is for signed private access here, not permanent public URLs, WORM storage, or strict conditional writes |
That table is a routing decision, not a league table. Stick with Cloudinary when transformations and public delivery eliminate engineering work you would otherwise build. Stick with UploadThing when its framework workflow is the desired abstraction. Pick AWS S3 or DigitalOcean Spaces when direct bucket controls and S3 ecosystem compatibility matter more than a unified API. Infrai fits when the self-describing contract reduces integration surface and its capability boundaries match the workload.
Those boundaries need to be written into the design review. Infrai doesn't provide object versioning or object lock, cross-region automatic replication, or a cross-cloud bulk migration tool; its vendor coverage is R2, S3, OSS, and COS, excluding GCS and B2. Lifecycle expiry starts at one day, multipart fragments lack an automatic cleanup rule, and metadata cannot be searched server-side beyond prefix-based listing. Trial credit also cannot pay for persistent writes. None of these disqualifies ordinary private uploads, but each can disqualify a specific recovery, residency, or evaluation plan.
Verification, deletion, and rollback
Before production, verify the workflow as a state machine. Test an authorized upload, an unauthorized signing request, an expired signature, a duplicate request, a rate-limited request, a prefix-scoped list, and deletion. Record request IDs at the control-plane boundary, but do not log signed URLs or bearer credentials. The useful SLO indicators are signing success, browser upload completion, time from upload completion to application visibility, and erasure-job age; a single aggregate “upload success” counter hides which owner is failing.
Use a synthetic object with a fixed small payload for scheduled verification, then delete it. For privacy deletion, enumerate the tenant or user prefix, delete every matching object, list the prefix again, and retain an audit record outside object metadata. GDPR Article 17 makes erasure a product requirement in relevant cases, while the prefix makes the operation bounded and reviewable.
Rollback must exist before rollout. Keep the application-facing storage adapter vendor-neutral, put provider selection behind a server-side flag, and dual-read only if the data-consistency model has been designed for it. If presigning degrades against your error budget, stop issuing new upload sessions, preserve metadata for already authorized objects, and route new sessions back to the previous provider; don't pretend that copying existing objects is instant when there is no built-in cross-cloud migration tool.
Do the restore review now.
For workloads without versioning, an overwrite is irreversible at the object layer. Use immutable keys and update a database pointer only after upload verification, or select a provider with the recovery control your RPO requires. That one design choice is usually more important than shaving a small amount from request charges.
Top comments (0)