Direct Browser Image Uploads Need CORS and Retention Boundaries (3 Layers)
Direct browser uploads look like a storage selection problem. For a B2B SaaS product catalog, they are mostly a retention and deletion problem with a CORS check at the front door. The durable design is three layers: a sh
Direct browser uploads look like a storage selection problem. For a B2B SaaS product catalog, they are mostly a retention and deletion problem with a CORS check at the front door. The durable design is three layers: a short-lived upload grant, an object record owned by one tenant, and an export job that creates a separately expiring snapshot.
TL;DR: Compare CORS support only after writing down who may upload, who may read an export, and when each object becomes deletable. Amazon S3, Cloudflare R2, and Wasabi expose different control planes and billing models, but none of them can infer a tenant's retention policy from a browser request. Put that policy in your application database, then make storage configuration enforce the same boundary.
The constraint is deletion, not the upload button
Imagine a SaaS workspace that uploads product images from the browser. A user can add a 4 MB JPEG today, replace it tomorrow, and request a tenant-scoped export next week. The original upload, the replacement, thumbnails, failed processing objects, and the export archive do not naturally share one lifetime. Treating them as a single folder creates an uncomfortable question: which object is safe to delete?
I start with an object ledger. It records the tenant identifier, object key, media type, byte count, creation time, retention deadline, deletion state, and the export jobs that reference the object. The server generates the key and includes an opaque tenant scope, for example tenant/7f3c/assets/2026/10/28/9a...; a browser never chooses the tenant prefix. The exact key shape is less important than making cross-tenant reads hard to express and easy to audit.
A CORS rule answers a browser question: may this origin read a response from this storage endpoint? It does not authenticate a tenant, authorize an object key, or decide whether an old image may be erased. Those decisions belong to the application and its records.
The browser is only one participant. The worker, database, and deletion log must agree on the same object identity, or a clean-looking upload flow can leave data behind.
This separation also handles a common trap. A successful upload response proves that bytes arrived. It does not prove that the object belongs in the catalog, passed validation, or is still needed. I keep an upload in pending until a server-side finalize step checks the grant, records the object, and queues image processing. Unclaimed pending objects get a short cleanup deadline.
Small boundary. Big difference.
What does a tenant-scoped export actually require?
An export is a read model, not a recursive listing of a tenant prefix. The export worker reads the ledger, selects records whose tenant ID matches the authenticated request, verifies their retention state, and writes an archive under a separate export namespace. The requester's authorization is checked before the job is created and again when the job starts. A copied job identifier must not become a second authorization mechanism.
The archive gets its own expiry. This matters when a customer downloads an export twice over several days: extending the archive's lifetime must not silently extend the lifetime of every source image. Conversely, deleting a source image should not corrupt an export that was already promised to the customer; the product must state which guarantee it makes and record that decision.
A useful state model is deliberately boring:
from dataclasses import dataclass
from datetime import datetime
from enum import Enum
class ObjectState(Enum):
PENDING = "pending"
ACTIVE = "active"
DELETE_REQUESTED = "delete_requested"
DELETED = "deleted"
@dataclass
class Asset:
tenant_id: str
object_key: str
state: ObjectState
retain_until: datetime
def may_include_in_export(asset: Asset, tenant_id: str, now: datetime) -> bool:
return (
asset.tenant_id == tenant_id
and asset.state is ObjectState.ACTIVE
and asset.retain_until >= now
)
The retain_until check is not a substitute for a deletion job. It prevents a stale record from entering a new export while a separate worker handles object removal, retries, and audit events. If legal hold or an unresolved billing dispute can pause deletion, model that explicitly rather than hiding it in a comment.
For browser access, grant the narrowest operation and lifetime that the workflow needs. The browser may upload one key with a content type and size expectation. It does not receive a general list permission. A download link for an export should be read-only and expire independently from the export record. A server-rendered Content-Disposition value can tell a browser how to present a downloaded file, but it does not enforce tenant authorization.
Delete the record last.
How should I choose cloud storage for CORS support in direct browser uploads?
The comparison becomes useful after the ownership model exists. Amazon S3, Cloudflare R2, and Wasabi all can sit behind an application that issues browser upload grants, but their configuration surfaces and surrounding operational assumptions differ. The practical question is not which name has the lowest headline storage figure. It is which service's CORS, lifecycle, object API, logging, and egress behavior fit the deletion contract you can operate.
| Service | Boundary worth testing | What the application still owns |
|---|---|---|
| Amazon S3 | Bucket CORS, object lifecycle, versioning, and policy controls are configured through its AWS-oriented control plane. | Tenant authorization, export selection, and the deletion ledger. |
| Cloudflare R2 | CORS and lifecycle settings live in a Cloudflare-managed object-storage surface, while the application still needs to verify its object-key contract. | Tenant authorization, export selection, and proof that old versions or failed objects are handled. |
| Wasabi | S3-compatible request patterns can reduce adapter work, but compatibility does not make lifecycle semantics or account policy identical to another service. | Tenant authorization, export selection, and tests for deletion and retention behavior. |
That table is a shortlist of experiments, not a recommendation. For each candidate, I use a disposable test bucket and test three origins: the production application origin, a preview origin, and an untrusted origin. I test preflight and the actual upload, then check that response headers are exposed only where the frontend needs them. A wildcard origin is a poor fit when credentials or tenant-specific reads are involved.
Every option has a boundary. A provider with S3-compatible requests may reduce adapter work, but that does not make retention timing, version cleanup, or account policy identical across services. A broader policy surface can help a team express complex controls, while also increasing the number of settings that need review. A simpler storage control plane can reduce configuration overhead, while moving more lifecycle evidence and tenant policy into the application. Those are engineering trade-offs, not reasons to rank one service for every workload.
I also test the unpleasant paths. Upload a zero-byte file, send an unexpected media type, retry a completed upload, abandon a grant, delete a source object referenced by an export, and run the cleanup worker twice. The expected result should be idempotent and observable. A storage provider can return a successful delete for an object that was already absent; the ledger should still reach one stable terminal state.
The product differences are real, but they do not remove the core application work. An S3-compatible API can make a migration easier at the request layer while leaving policy, lifecycle timing, version cleanup, and operational alerts to your team. A managed control plane can centralize configuration while still requiring an integration test for the exact browser origin and headers. Keep those as explicit acceptance criteria.
The failure modes that change the design
The first failure is an overly broad upload grant. If a user can replace the key portion of a signed request, the browser becomes a confused deputy for another tenant. Bind the grant to an exact object key, expected content type, maximum size, and short expiry. Enforce the same constraints during finalization; a client-side file check is only a user experience feature.
The second is retention by prefix alone. Prefixes are useful for searching and lifecycle rules, but they cannot represent a legal hold, a pending export, or a failed image derivative. A database record must be the source of truth for those states. Storage lifecycle rules are a backstop for abandoned data, not the complete retention policy.
The third is forgetting derived objects. A product image may produce a thumbnail, a web-optimized copy, and an export entry. Deleting the original while leaving derivatives behind can violate the user's expectation even when the original key is gone. Store a parent asset ID on every derivative and make deletion traverse that relationship.
The fourth is treating CORS errors as authorization errors. A browser may block JavaScript from reading a response while the request has already reached the storage service. Log the request ID and application event on the server side, and inspect the browser's preflight separately from the object policy. This distinction saves hours of debugging and prevents a team from widening permissions just to make a console message disappear.
The fifth is making export delivery permanent. An export often contains more data than any individual image URL. Record who requested it, which asset revision IDs it contains, when it expires, and whether a tenant can revoke it. For regulated workflows, portability is a product promise with an audit trail, not just a ZIP file.
The race around replacement deserves a concrete test. A user uploads blue-chair.jpg, the catalog points at revision 12, and an export worker reads that row just as the user replaces it with revision 13. If the worker resolves the object key later from a mutable asset record, the archive can contain revision 13 while its manifest says 12. I snapshot the immutable revision ID when the job is created, then stream exactly that key and record a missing-object result instead of silently substituting the newest image. The cleanup worker waits until no active export references the revision, or until the product's explicit export guarantee expires. This is extra bookkeeping, but it makes deletion explainable when a customer asks what was in an export.
A compact rollout plan
I would ship the boundary in this order. First, create the ledger and a server endpoint that issues one constrained upload grant. Second, finalize uploads and quarantine unclaimed objects. Third, implement tenant-scoped export selection from the ledger, with a separate export expiry. Fourth, add deletion retries, metrics for pending objects past their deadline, and a report of objects with no ledger row. Only then would I tune the storage provider's CORS and lifecycle settings for production.
Use a small fixture set: two tenants, three assets each, one replacement, one abandoned upload, one thumbnail per asset, and one export spanning a subset. Six or seven cases expose more boundary mistakes than a large happy-path dataset. The test should assert both the object keys that exist and the records that are eligible for export.
Keep the migration reversible. Write an adapter around generic operations such as create upload grant, finalize asset, stream export member, and request deletion. Do not leak provider-specific response shapes into the tenant service. During a move, copy only active ledger rows, preserve asset IDs, and compare manifests before changing the read path. A retention deadline that changes because of a migration is a data issue, not a deployment detail.
The final decision can then be made with evidence: CORS behavior for the real origins, exact deletion and version behavior, export auditability, observability, operational ownership, and the exit plan. Storage capacity is one input. The boundary that prevents one tenant's image from appearing in another tenant's export is the design.
References
The HTTP header behavior comes from the MDN reference. The object-storage comparison should be checked against each provider's current documentation and a test account before production rollout. GDPR Article 20 is useful context for export expectations, but it does not prescribe a particular storage provider or architecture.
Sources
- https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Content-Disposition
- https://developers.cloudflare.com/r2/
- https://gdpr-info.eu/art-20-gdpr/
- https://docs.aws.amazon.com/AmazonS3/latest/userguide/enabling-cors-examples.html
- https://docs.wasabi.com/docs/how-do-i-use-cors-with-wasabi
Originally published by Dev.to WebDev. Aggregated on AIWithGhost for educational purposes β full credit and traffic to the original publisher.