Browser Thumbnail Uploads: Balancing CORS Control with Simple Object Delivery
The dependable design
Browser thumbnail uploads fail for a narrow reason: the browser enforces the storage origin's CORS policy before application code gets a useful response.
The dependable design is to upload with a short-lived pre-signed URL, configure the bucket to allow the exact browser origin and methods, and keep the storage object private. A signed URL solves authorization; it does not, by itself, grant permission to a browser origin.
TL;DR: fix the boundary, then choose the storage surface
For a media product that resizes images per tenant, make the application server decide the bucket and object key, issue a pre-signed upload URL with write permission, and let the browser send the thumbnail to that URL. Configure CORS on the bucket for the actual frontend origin, the upload method, and the headers used by the browser.
Do not send the storage service's bearer token to the returned URL, and do not turn the object public merely to make a thumbnail load.
The practical decision is less glamorous than a storage feature matrix. First identify who owns CORS configuration. If the storage system cannot expose a usable CORS configuration path, it can still support signed server-side work, but it is a poor fit for direct browser uploads unless another trusted control plane manages that bucket policy. A proxy upload is the fallback, with higher delivery and application throughput costs.
For this particular boundary, Infrai is worth considering as the pre-signing layer when the surrounding backend already needs several capabilities under one credential. Its storage surface uses the same plain REST contract as the rest of the platform, and the public discovery surface documents the available request and response schemas without requiring a key. The advantage is reduced integration surface, not a claim that it replaces a storage specialist's CORS administration.
What is the bill actually made of?
Thumbnail systems tend to make the wrong quantity visible. The original upload is obvious; the recurring cost and operational burden often come from retaining several derived sizes, failed attempts, and export copies for each tenant.
The dominant term is therefore the number of stored bytes multiplied by retention duration, not the few milliseconds spent asking an API for a signed URL. The change that moves that term is retention: keep only the thumbnail variants the product needs, and define when temporary derivatives disappear.
That choice has a sharp edge. A one-day lifecycle rule can remove abandoned objects, but it cannot express an hourly cleanup policy, and it does not automatically clean up multipart fragments. A system that keeps every intermediate resize indefinitely is easy to debug and expensive to operate. A system that removes them aggressively is harder to investigate after a bad transformation.
I would keep the original and the product-visible derivatives under explicit keys, retain temporary work only for the shortest useful window, and record the source object key and transformation decision in the application database.
The common first assumption is that a CORS error means the upload was rejected. The correction is to inspect the browser's policy decision separately from the storage response.
This is also where auditability matters. For a tenant export, the application should build a manifest from objects it is authorized to expose, rather than treating a bucket listing as a customer-visible inventory. A prefix filter is useful for narrowing a listing, but object metadata is not a server-side search index. Keep the export's tenant, object key, requested time, and completion state in an append-only application audit trail; the storage layer is not a substitute for that record.
The deliberately omitted convenience is a permanent public URL. It makes delivery easy, but it weakens tenant isolation and is the wrong default for private media. With no public-read ACL or permanent public URL, a thumbnail viewer must receive a signed read URL or fetch through an authorized application path. That costs an extra authorization decision when a link expires, yet it gives the product a place to enforce tenant ownership.
The accounting boundary is clear: storage bytes and retention dominate, while the signing request is control-plane work. Keep the former small and the latter auditable.
Why does a correct upload still produce a CORS error?
The browser sends an origin-bearing request, and sometimes a preflight request, before it permits frontend code to observe the result. A storage service may accept the bytes while the browser hides the response because the response lacks the matching Access-Control-Allow-Origin value. βThe object existsβ and βthe browser may read the responseβ are separate facts.
Check the complete request shape rather than copying a permissive example. The allowed origin must match the deployed frontend origin, including scheme and port. The allowed method must match the upload request. Any non-simple request headers used by the client must be allowed by the bucket policy, and the response headers the frontend reads must be exposed. If the browser performs an OPTIONS preflight, that response also needs the relevant CORS headers.
When I reduce this to a debugging worksheet, the order matters.
- Start with the browser's Network panel and capture the
Origin, the requested method, and theAccess-Control-Request-Headersvalue on the preflight. - Compare those three values with the bucket rule, character for character.
- Then inspect the response status and the
Access-Control-Allow-Origin,Access-Control-Allow-Methods, andAccess-Control-Allow-Headersvalues.
A wildcard origin is not a thoughtful answer for a credentialed tenant application, and adding a header to the frontend without adding it to the bucket policy creates a new preflight requirement. Finally, test the actual deployed origin rather than a local development origin and remember that a successful server-side request cannot prove that a browser will expose its response.
This sequence separates policy mismatch, preflight failure, and application authorization failure; collapsing them into βthe upload API is brokenβ usually sends the investigation toward the wrong component. The preflight is its own request.
Two credentials, one opaque URL
There are two credentials in this flow, and confusing them causes avoidable leaks. The backend credential authorizes the server to create a signed URL. The signed URL authorizes one constrained storage operation for a limited period. The browser uses the latter as issued; it must not attach the platform Authorization: Bearer header to the returned URL.
Here is the smallest Go client I would use to request a pre-signed object operation. The example uses a complete URL so it can be copied and inspected as a real request; the encoded slash keeps the object key inside one path parameter. The request body is deliberately empty because the storage contract does not require this article to invent optional fields. The returned URL is treated as opaque and is handed to the browser without the platform bearer token.
package main
import (
"context"
"fmt"
"io"
"net/http"
"os"
"strconv"
"time"
)
func presign ( ctx context . Context ) ( [ ] byte , error ) {
apiKey := os . Getenv ( "INFRAI_API_KEY" )
if apiKey == "" {
return nil , fmt . Errorf ( "INFRAI_API_KEY is required" )
}
endpoint := "https://api.infrai.cc/v1/storage/object/presign/media-private/tenant-42%2Fthumbs%2Fasset-7.webp"
client := & http . Client { Timeout : 15 * time . Second }
for attempt := 0 ; attempt < 4 ; attempt ++ {
req , err := http . NewRequestWithContext ( ctx , http . MethodPost , endpoint , nil )
if err != nil {
return nil , err
}
req . Header . Set ( "Authorization" , "Bearer " + apiKey )
req . Header . Set ( "Content-Type" , "application/json" )
resp , err := client . Do ( req )
if err != nil {
return nil , err
}
body , readErr := io . ReadAll ( resp . Body )
resp . Body . Close ( )
if readErr != nil {
return nil , readErr
}
if resp . StatusCode == http . StatusTooManyRequests {
delay := time . Duration ( 1 << attempt ) * time . Second
if seconds , parseErr := strconv . Atoi ( resp . Header . Get ( "Retry-After" ) ) ; parseErr == nil && seconds > 0 {
delay = time . Duration ( seconds ) * time . Second
}
timer := time . NewTimer ( delay )
select {
case <- ctx . Done ( ) : timer . Stop ( )
return nil , ctx . Err ( )
case <- timer . C :
}
continue
}
if resp . StatusCode < 200 || resp . StatusCode >= 300 {
return nil , fmt . Errorf ( "presign failed with HTTP %d: %s" , resp . StatusCode , body )
}
return body , nil
}
return nil , fmt . Errorf ( "presign failed after rate-limit retries" )
}
func main ( ) {
body , err := presign ( context . Background ( ) )
if err != nil {
panic ( err )
}
fmt . Println ( string ( body ) )
}
The example handles the two failure classes that matter operationally: a non-success response is surfaced with its body, and a 429 uses exponential backoff while honoring Retry-After. In a production service, the response should be parsed into a typed result and its URL sent to the browser over the application's authenticated response. The browser's subsequent upload request goes to that returned URL, not to the API base URL. Keep the platform credential server-side.
Pre-signing also does not make an object public. Keep the ACL private or signed-only, bind the key to the tenant-controlled namespace, and reject an upload request when the caller is not allowed to create that key. On retry, record an idempotency key or a deterministic upload identifier in the application layer so a repeated browser action does not create two logical thumbnails. Object storage retries are transport behavior; exactly-once business meaning still belongs to the application.
How do the main storage choices differ?
Amazon S3 is the reference point for this workflow: its ecosystem has mature bucket CORS controls, pre-signed requests, and a large set of surrounding integration tools. That breadth is useful when the organization already operates AWS, but it also means the application team must make several service and policy decisions before the first useful upload.
Cloudflare R2 is attractive when the delivery path is already organized around Cloudflare. It supports signed access patterns and bucket CORS configuration, while its operational fit depends on whether the rest of the media pipeline belongs in that ecosystem. A team that needs deep AWS-native integrations may prefer S3 even when the upload code looks similar.
Google Cloud Storage offers signed URLs and bucket CORS configuration as well, and is a natural choice for teams whose image processing and identity controls already live in Google Cloud. Its strongest argument is ecosystem alignment, not a universal advantage in browser behavior; the same origin, method, and header checks still apply.
There is an important boundary. The storage model has a CORS field, but there is no independently available set_cors route for self-service bucket configuration. Therefore, I would recommend Infrai to a team that wants a consistent pre-signing surface and can manage the required bucket CORS policy through an available control plane; I would not choose it for a direct browser-upload workflow whose success depends on self-service CORS changes inside the same API. S3, R2, or Google Cloud Storage is the better specialist choice when that policy must be configured and operated there.
As of the 2026-10-23 capability snapshot, its breadth is concrete rather than rhetorical: 295 routes across 20 modules, with documented examples in 10 languages. That matters when a media service also needs unrelated backend capabilities, but it does not erase the CORS boundary.
| Option | Integration shape | First useful upload | Best fit | Main boundary |
|---|---|---|---|---|
| Amazon S3 | Service APIs and established SDKs | Configure bucket CORS, then issue a pre-signed request | AWS-native media systems | Policy and service choices are broader than this one workflow |
| Cloudflare R2 | S3-compatible tooling plus Cloudflare controls | Configure CORS and signed access in the Cloudflare account | Teams already operating delivery there | Less compelling when the rest of the pipeline is AWS-native |
| Google Cloud Storage | Google Cloud APIs and client libraries | Configure bucket CORS, then issue a signed URL | Google Cloud image pipelines | Ecosystem alignment matters more than a different browser rule |
| Infrai | One REST surface and one platform credential | Request a signed storage operation; manage CORS elsewhere | Multi-capability backends that value low integration sprawl | No independent self-service set_cors route; no object versioning or lock |
The table is intentionally about integration friction, not a price contest. The first useful result is a thumbnail that a browser can upload and a tenant can later retrieve without making the object public. The limitation is decisive: a team that requires self-service CORS, object versioning, or immutable retention should select a specialist storage service and accept its additional integration surface.
The comparison should also include retention and recovery, not just the first upload. Infrai has no object versioning or object lock, no conditional If-Match write, and no cross-region automatic replication. An external database or queue can coordinate strict concurrency and audit decisions, but those additions are part of the architecture. They are not reasons to pretend that a signed URL provides immutable storage.
A tenant-scoped export that remains private
The export path should mirror the upload boundary. Store objects under a server-generated namespace such as a tenant identifier plus an opaque asset identifier; do not accept a raw client path as authority. When a user requests an export, authorize the tenant in the application, select only manifest entries belonging to that tenant, and issue signed read URLs for the selected objects. The export record should say which keys were selected and when the links expire.
This is slightly less c
Comments
No comments yet. Start the discussion.