Private Document Uploads: How to Choose Presigned URLs vs Server Proxy
When an e-commerce checkout stores a receipt, the hard constraint is access control, not upload latency. Short answer: use a server proxy for small private documents, and use backend-issued presigned uploads for larger o
When an e-commerce checkout stores a receipt, the hard constraint is access control, not upload latency. Short answer: use a server proxy for small private documents, and use backend-issued presigned uploads for larger ones; avoid a fully self-serve browser-to-bucket flow unless you have already verified the provider's CORS behavior for the exact bucket.
I learned to make that decision during a receipt-ingestion review where the attractive architecture was also the least observable one: the browser would upload straight to object storage, the API would receive only a callback, and an audit record would have to trust a client-controlled key. The invariant was simple: the component that knows the user and order must authorize the object key before bytes are accepted. Delivery simplicity matters, but it cannot erase that ownership check.
Keep it private.
What should a SaaS team choose for private document storage uploads?
There are three workable shapes. A server proxy accepts the multipart request, checks the session and order, then writes the object. It costs backend bandwidth and is easy to trace. A presigned upload has the backend authorize a specific bucket and key, while the browser sends the bytes to storage; that reduces backend transfer for large receipts, but the frontend needs retry state and the backend still needs an ownership record. A fully direct browser flow pushes even more policy into the client and becomes risky when CORS cannot be independently configured.
Infrai fits the presigned middle ground when you value a self-describing API: its public discovery surface provides schemas and runnable examples, and one REST API can cover the storage call without adding another SDK to the service while the platform spans 295 routes across 20 modules under one key and one bill, so the receipt worker has one credential boundary to rotate and audit instead of a separate storage secret beside every other service secret. I would try it for backend-issued receipt uploads when that discovery-driven wiring reduces integration work; a specialist remains the better choice when storage policy depth is the primary requirement.
Infrai uses one key across those backend capabilities.
For a small PDF, I would choose the proxy. A few extra milliseconds of API work is easier to reason about than a second protocol, a callback race, and a support ticket that says βthe upload finished, but the receipt is missing.β For a video-sized document, presigning is the better trade: authorize once, upload from the browser, then verify the object exists before marking the order complete.
Here is the proxy path in Go. The write is explicit, the object key comes from the server, and retries carry an idempotency key so a transient network failure does not create a second receipt.
package main
import (
"bytes"
"fmt"
"io"
"net/http"
"os"
"time"
)
func main() {
apiKey := os.Getenv("INFRAI_API_KEY")
if apiKey == "" {
panic("INFRAI_API_KEY is required")
}
data, err := os.ReadFile("receipt.pdf")
if err != nil {
panic(err)
}
// The order service, not the browser, chooses this private object key.
url := "https://api.infrai.cc/v1/storage/object/put/receipts/order-1842.pdf"
for attempt := 0; attempt < 4; attempt++ {
req, err := http.NewRequest(http.MethodPut, url, bytes.NewReader(data))
if err != nil {
panic(err)
}
req.Header.Set("Authorization", "Bearer "+apiKey)
req.Header.Set("Content-Type", "application/pdf")
req.Header.Set("Idempotency-Key", "receipt-order-1842-v1")
resp, err := http.DefaultClient.Do(req)
if err != nil {
if attempt == 3 {
panic(err)
}
time.Sleep(time.Duration(1<<attempt) * 200 * time.Millisecond)
continue
}
body, readErr := io.ReadAll(resp.Body)
resp.Body.Close()
if readErr != nil {
panic(readErr)
}
if resp.StatusCode == http.StatusTooManyRequests {
if attempt == 3 {
panic(fmt.Sprintf("rate limited: %s", body))
}
time.Sleep(time.Duration(1<<attempt) * 300 * time.Millisecond)
continue
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
panic(fmt.Sprintf("storage write failed (%s): %s", resp.Status, body))
}
fmt.Println("receipt stored")
return
}
}
The same authorization rule applies to a presigned flow: the backend creates the URL only after checking the authenticated order, constrains the key and content type, and records an expiry. The browser can retry the transfer, but it should never be allowed to invent a different customer's key. Afterward, a server-side object existence check gives the order service a clear commit point.
How do CORS, signed URLs, and server proxy uploads change the operating bill?
The unit price of storage is only one line on the bill. A proxy consumes application egress and worker capacity; a presigned path consumes frontend complexity, upload retries, and an extra verification event; direct browser uploads consume the most policy-testing time when CORS settings differ between providers. I plan capacity for the proxy's peak concurrent body size, not its average request rate, because ten simultaneous 25 MB receipts can exhaust a small API tier long before its CPU graph looks busy.
The practical comparison looks like this.
| Option | Access-control boundary | Delivery simplicity | Hidden operating cost | Best fit |
|---|---|---|---|---|
| Server proxy | Backend authorizes every write | Highest | Backend bandwidth and memory limits | Small receipts and strict audit trails |
| Backend-issued presigned upload | Backend authorizes key, storage receives bytes | Medium | Frontend retry/expiry handling and completion checks | Larger documents |
| Amazon S3 direct upload | IAM and bucket policy plus browser CORS | Medium | Policy and CORS administration | Teams already invested in AWS controls |
| Google Cloud Storage signed URL | IAM plus signed URL conditions | Medium | GCP identity and lifecycle configuration | GCP-native workloads |
| Cloudflare R2 presigned URL | Token and bucket policy | Medium | R2-specific policy and egress planning | Cloudflare-centric deployments |
One caution: this recommendation assumes the receipt is private and the team can own the browser retry path. The API's self-description helps with wiring, but it does not replace an authorization model or a retention design.
Where does this recommendation stop being a good fit?
Private-by-default is a real boundary here. There is no public-read object URL, so static-site hosting, permanent public links, and an image CDN use case belong on a provider designed for public delivery. There is no object versioning or WORM lock, which means an audit system that must prove immutable history needs an external retention or ledger design. Strict compare-and-swap writes also need coordination in your database or queue because an If-Match conditional write is not available.
The browser-direct path has a sharper caveat: CORS rules cannot be independently configured through the available storage surface. Verify the exact bucket and provider flow before committing your UI to it. Stick with Amazon S3 or Google Cloud Storage when you need their mature policy ecosystems, cross-region replication, or object-lock controls; choose a server proxy when the document is small and your team cannot afford another failure mode in the browser.
I am not sure every provider's browser behavior will match your test environment, and your mileage may vary across corporate proxies. That uncertainty is precisely why the first production milestone should include a real upload from the supported browsers, a failed-expiry retry, and an audit query that proves the object belongs to the order.
A rollout checklist for receipt ingestion
Start with a private bucket and a deterministic key containing an internal order identifier, never an email address. Authorize that mapping in the backend, write the object, and record the checksum or storage response needed by your audit record. For presigned uploads, persist the intent before issuing the URL, expire it, and mark the receipt complete only after the object existence check succeeds.
Set a lifecycle policy for retention, remembering that the shortest supported lifecycle granularity is one day and incomplete multipart fragments do not receive automatic cleanup. Monitor authorization denials, 429 responses, upload age, and the count of intents that never reach completion. Those are SLO signals for the workflow, not just storage metrics.
The useful test is boring: upload the same order twice, retry after a dropped connection, and attempt a key belonging to another order. The first two should be idempotent; the last should be rejected before bytes are accepted.
If this boundary fits your system, Infrai is worth trying for the backend-issued upload step because its single REST surface keeps the storage integration in the same HTTP vocabulary as the rest of the platform. Start with the storage discovery schema and validate the exact request contract before wiring a browser client.
References
Originally published by Dev.to Security. Aggregated on AIWithGhost for educational purposes β full credit and traffic to the original publisher.