Use an application-owned upload boundary for customer-support thumbnails unless the production browser origin, private read path, and tenant policy have passed an end-to-end test. Direct browser uploads can reduce application bandwidth for large originals, but they move part of the security contract into CORS and signed-request configuration, where a successful upload alone proves very little.
Short answer: direct browser thumbnail uploads to a private bucket fail when CORS, authorization, and the later private read are treated as one feature; separate those controls, bind every object key to a tenant-owned record, and choose server mediation when that contract cannot be operated reliably.
The useful unit here is not the PUT. It is the whole path: a support customer selects an image, the service accepts it, a worker creates a derivative, and an authorized agent can display that derivative without seeing another tenant's object. That path needs its own SLOs and failure signals.
The browser is only one participant.
Why do direct browser thumbnail uploads to private object storage fail on CORS and presigned limits?
CORS is a browser permission check. A presigned request is an authorization artifact for a particular operation, object, and expiry window. One does not configure the other. A shell request may succeed while the browser rejects a preflight because the origin, method, or requested header is absent from the storage CORS policy. Conversely, a permissive CORS response does not grant access to a private object.
There is a second trap after the write. A thumbnail that exists but cannot be read in the support console is still a failed workflow. The UI needs a short-lived authorized read or an application proxy, and that read must be checked against the same tenant context that authorized the upload. Do not turn an object key into a capability by exposing predictable names such as tenant-17/latest.png.
That distinction is easy to lose during an incident.
The production signal should distinguish at least four stages: preflight, upload, resize, and authorized display. A 403 during display is a different incident from a rejected preflight, even if both appear to the agent as “thumbnail missing.” I would page on the user-facing SLO, but preserve those stage labels in logs so the on-call can act on the actual boundary.
Make tenant isolation a storage invariant
Authorization should produce the object key, rather than accept a browser-supplied key and check it afterward. A practical record contains a tenant identifier, an image identifier, the original key, the derivative key, and the current processing state. The database owns the relationship; object storage holds bytes under opaque, immutable names.
For example, an application can authorize a key shaped like tenants/t-204/conversations/c-81/images/i-9/original. The value is illustrative, but the rule is not: derive it from authenticated server state, use a fresh image identifier, and never reuse a shared “current” object name across concurrent requests. The resize worker should receive the image record, validate that the source belongs to the same tenant, and write a derivative that is also addressed by that record.
The browser may carry a file and a short-lived signed request, but it should not decide canonical ownership, derivative names, or which conversation receives the image. Those decisions belong behind the application boundary. This is where capacity planning matters: measure ingress bytes, connection occupancy, worker queue delay, and authorized-read latency separately instead of hiding all four inside one upload timeout.
Choose the boundary with a buy-vs-build review
There is no universally correct upload shape. The right choice depends on original size, tenant policy, and how much storage configuration the team can test and own.
| Boundary | Buy | Build and operate | Choose it when | Avoid it when |
|---|---|---|---|---|
| Application-mediated upload | A single policy point and straightforward validation | Application bandwidth, request limits, and image-worker capacity | Files are modest and isolation is the dominant risk | Originals are large enough to consume the service's connection budget |
| Application-issued presigned upload | Less application byte transit | CORS policy, expiry, private reads, and cleanup become part of the release contract | The browser path is tested from the real production origin | The team cannot observe or change the required storage CORS policy |
| Provider-specific storage integration | Mature provider features and established operational tooling | SDK coupling, provider configuration, and a larger portability cost | The organization needs provider-native behavior | A stable cross-provider call surface matters more than native features |
| Thin storage abstraction | One application contract for several backends | Capability gaps, translation rules, and a separate compatibility test suite | The required operations are deliberately small and portable | Versioning, retention locks, or advanced replication are hard requirements |
This is a capacity decision, not a reflexive preference for fewer HTTP hops. For a large original, direct transfer may be sensible; for a small support attachment with strict tenant checks and a resize job, server mediation often buys a simpler control plane. The catch is that application mediation is not suitable when its ingress SLO cannot absorb the chosen file limit. Stick with a direct flow when measured headroom is inadequate, but only after the browser contract is part of deployment testing.
Implement a boring, observable path in Go
The service should create the image record before issuing upload material, enforce a size and media-type policy, and make retries idempotent at the record level. A client retry must not create two logical attachments because the first response was lost. The code below shows the application-side boundary; the storage adapter remains behind a small interface so it can be tested without a browser or a real bucket.
package uploads
import (
"context"
"fmt"
)
type UploadRequest struct {
TenantID string
ConversationID string
ImageID string
ContentType string
SizeBytes int64
}
type Authorizer interface {
CanAttachImage(context.Context, string, string) error
}
type KeyStore interface {
CreateImage(context.Context, UploadRequest, string, string) error
}
func PrepareUpload(ctx context.Context, auth Authorizer, store KeyStore, req UploadRequest) (string, error) {
if err := auth.CanAttachImage(ctx, req.TenantID, req.ConversationID); err != nil {
return "", fmt.Errorf("authorize image: %w", err)
}
if req.ImageID == "" || req.SizeBytes <= 0 || req.ContentType == "" {
return "", fmt.Errorf("invalid image metadata")
}
originalKey := fmt.Sprintf("tenants/%s/conversations/%s/images/%s/original", req.TenantID, req.ConversationID, req.ImageID)
derivativeKey := fmt.Sprintf("tenants/%s/conversations/%s/images/%s/thumbnail", req.TenantID, req.ConversationID, req.ImageID)
if err := store.CreateImage(ctx, req, originalKey, derivativeKey); err != nil {
return "", fmt.Errorf("record image: %w", err)
}
return originalKey, nil
}
The real adapter must bind the signed operation to originalKey, the expected content type, and an expiry appropriate for the upload. It should return a client-visible reason for a rejected request, while logs retain tenant and image identifiers without recording file contents or long-lived credentials. If the adapter supports multipart transfer, use it only when its incomplete-part cleanup and retry behavior are understood; the multipart workflow has distinct completion and cleanup concerns from a single object write.
Treat a successful write as an event that starts processing, not as proof that the thumbnail is ready. The worker verifies the source record, writes the derivative to its immutable key, and updates state transactionally with the database pointer. A failed resize should leave a diagnosable processing state and a bounded retry policy. It should not make the browser guess whether to upload the same file again.
Verify, canary, and roll back without deleting evidence
The release check must run from the exact browser origin used by the support console. Test the preflight, the upload with real headers, the resize completion, and a fresh-session authorized read. Include a second tenant and assert that its request cannot retrieve the first tenant's derivative. A green command-line request is not a browser test. For a concrete check, create two support conversations with the same filename, upload one image from each, and then swap their session credentials while requesting the thumbnail URLs. The expected result is not merely “the object is private”: tenant A must receive its own derivative, tenant B must receive its own derivative, and each cross-tenant request must be denied without revealing whether the other key exists. Repeat the sequence after a client timeout, after a worker retry, and after a deployment that changes the browser origin. That longer test catches the failure that unit tests routinely miss, where storage authorization is correct in isolation but an object key, database pointer, or signed read was assembled from the wrong tenant context.
Record the image identifier in each stage metric. Useful measurements are preflight rejection rate, upload completion latency, resize queue age, authorized-read latency, and the count of objects with no matching image record. Set an SLO for the complete thumbnail workflow, then use the stage data to decide whether a breach belongs to policy, capacity, or processing.
This is the rollback rule: stop issuing new direct-browser upload material and route new files through the application boundary, while leaving existing immutable objects and database pointers intact. Do not delete objects as a first response; deletion removes evidence and can turn an access-policy incident into data loss. Reconcile unreferenced objects with a reviewed retention job after the policy is stable.
I'm not sure a single architecture remains right as file sizes and regions change; the missing evidence is measured ingress headroom under the busiest support workload. Your mileage may vary. Make that measurement a prerequisite for changing the boundary, and keep tenant authorization invariant whichever path carries the bytes.
Top comments (0)