DEV Community

BramwellVance7953
BramwellVance7953

Posted on

Object Storage vs Backend Proxy for Private Thumbnail Uploads 2026 (Tenant Isolation)

Use direct browser-to-object-storage uploads for support attachments when the storage service has a CORS policy you can test and your authorization service owns the object key; use a backend proxy when tenant isolation depends on checks the browser cannot safely express. A private bucket and short-lived presigned download link should be the default for the resulting thumbnail, not a public URL.

The browser error is usually a symptom, not the design decision. A preflight can fail because OPTIONS is not allowed, because the requested headers are absent from Access-Control-Allow-Headers, or because the upload response does not expose the header your client reads. A successful PUT can still look like failure to JavaScript when the response is not CORS-readable. I have seen teams spend a day changing a bucket ACL when the actual mismatch was an origin with a different port. The useful diagnostic is a captured browser request: compare the Origin, method, signed headers, and response exposure one by one, then repeat from the deployed hostname rather than a local development port. That procedure also catches a less obvious case where the upload succeeds but the completion request is blocked, leaving the database stuck in pending even though the object exists. It is a long afternoon if you inspect only the storage console, because the console cannot show what the browser refused to reveal to script.

Keep it mechanical.

Why do object storage browser thumbnail uploads fail on CORS?

The browser treats a cross-origin upload as a contract. The contract covers the origin, method, request headers, and the response headers that script may inspect. A presigned URL authenticates a particular request; it does not grant the page permission to ignore the browser's same-origin policy. Those are separate controls.

For a customer-support attachment, the useful sequence is deliberately boring:

  1. The support API authenticates the agent and resolves a tenant-scoped object key.
  2. It returns a short-lived upload URL and the exact headers the browser must send.
  3. The browser uploads the original, then reports completion to the API.
  4. A worker validates bytes and writes a derivative thumbnail under another tenant-scoped key.
  5. A download request returns a fresh signed URL after an authorization check.

Do not let a filename from the form become a storage path. Normalize MIME policy on the server, bind the key to the tenant ID, and keep the database record in a pending state until a HEAD-style verification confirms size and content type. The storage service is a byte store; it is not your authorization database.

Here is the shape of the client-side contract in Go pseudocode. The endpoint names belong to your application, so the example intentionally avoids pretending that every storage provider has the same API.

type UploadGrant struct {
    URL     string            `json:"url"`
    Method  string            `json:"method"`
    Headers map[string]string `json:"headers"`
    Key     string            `json:"key"`
}

func validateGrant(g UploadGrant, origin string) error {
    if g.Method != "PUT" || g.URL == "" || g.Key == "" {
        return fmt.Errorf("invalid upload grant")
    }
    if origin == "" {
        return fmt.Errorf("missing browser origin")
    }
    return nil
}
Enter fullscreen mode Exit fullscreen mode

The browser should send exactly the signed headers. Adding x-amz-*, a custom checksum, or a different Content-Type after signing can invalidate the request even when CORS is configured correctly. Keep the upload response opaque to business logic: your API, not a client-side status guess, decides whether the object is ready for resizing.

How should private buckets, presigned uploads, and tenant isolation fit together?

Tenant isolation is the primary design axis here. Separate buckets can help with policy boundaries, but a per-tenant prefix plus an authorization record is often easier to operate; either way, every read, write, delete, and thumbnail job needs a tenant check before it receives a key or signed URL.

The key should be unguessable and immutable, for example tenant-42/ticket-918/original/8f.... Keep the logical attachment ID in your database and never derive authorization from a key supplied by the browser. On download, check the attachment's tenant and ticket permissions first, then mint a URL with a short expiry. A signed URL is a bearer token, so logging it in a support dashboard or analytics event defeats the point.

The thumbnail is a new object, not an overwrite of the original. That makes retries idempotent: a worker can reprocess original/8f... into thumb/256/8f... and compare a content hash before replacing metadata. For private support data, permanent CDN links are the wrong primitive. The catch is that every image view now has a signing step and an expiry policy; if a product requires anonymous, cacheable, never-expiring assets, stick with a public-content delivery design and accept its different access model.

Direct upload or backend proxy: what does the capacity plan say?

The choice is mostly about where bytes travel and where policy runs. A proxy gives one controlled network boundary and simple browser behavior, but your API fleet carries every upload byte, including retries. Direct upload removes that hop and scales better for bursty attachments, while making CORS, signed-header fidelity, and completion reconciliation part of the application contract.

Decision point Direct browser upload Backend proxy
Tenant authorization API grants a scoped key and URL API streams bytes after checking each request
CORS exposure Required and tested per origin Browser only talks to your API
App bandwidth Low; storage receives the body High; API receives and forwards the body
Failure handling Track pending uploads and verify completion Retry the stream at the proxy boundary
Best fit Medium files, many tenants, bursty traffic Strict inspection, simple networks, small files

Capacity planning needs one more number than “uploads per minute”: concurrent bytes in flight. If 200 agents upload 8 MB attachments through a two-zone API, a proxy can turn a modest request count into gigabytes of egress and connection pressure. Model the worst five-minute burst, worker CPU for thumbnail decoding, and the time needed to drain a queue while meeting the attachment SLO. Your mileage may vary with mobile networks; a direct upload that is efficient on office broadband may need resumable parts for field users.

Multipart upload is useful for large or unreliable transfers, but it adds lifecycle state and cleanup work. AWS documents that incomplete multipart uploads can incur storage charges until they are completed or aborted, so a lifecycle rule and an explicit abort path belong in the runbook. For ordinary ticket screenshots, a single signed PUT is easier to reason about.

The market differences are operational, not magical. Amazon S3, Cloudflare R2, and Google Cloud Storage all expose object storage with signed-request patterns, but CORS configuration surfaces, egress economics, lifecycle controls, and regional behavior differ. Do not infer policy defaults from API compatibility; test the exact browser origin and headers against the selected service. I’m not sure a benchmark from one region predicts your support team's traffic, so measure upload latency and thumbnail queue age in the regions you actually serve.

What should verification and rollback look like after a thumbnail upload?

Verification is a state machine, not a happy-path callback. Record pending, uploaded, processed, and rejected states with timestamps. After the browser reports success, the API verifies object existence, byte length, allowed media type, and tenant prefix before enqueueing resize work. A worker writes the derivative, records its dimensions, and only then exposes it to the ticket UI.

Observe three SLOs separately: grant latency, upload completion rate, and time from upload to usable thumbnail. A CORS regression often appears as a sharp drop in completion rate with normal grant latency. Alert on the combination, and capture the origin, method, and non-secret header names in logs. Never log the full presigned URL.

Rollback should be boring too. Stop issuing new grants for the affected client version, leave existing private objects untouched, and replay only pending records after the CORS or worker configuration is corrected. A cleanup job can delete abandoned originals after a retention window, but it must query your database for tenant ownership before deleting. If the provider cannot meet your required regional redundancy or migration controls, the answer is to change the storage architecture, not to weaken authorization.

For a small support product, choose the simplest flow that meets the isolation and SLO budgets: private bucket, server-selected key, short-lived presigned upload, post-upload verification, and a separately signed thumbnail download. Choose a proxy when inspection or network policy is more important than app bandwidth. Neither path removes the need to test CORS in a real browser.

References

Top comments (0)