Generated-image uploads from a browser should use a short-lived, tenant-scoped upload grant only when the server can decide publication after the bytes arrive. For a US/EU SaaS, diagnose the browser's CORS preflight and the presigned PUT as separate contracts, then keep regional storage and publication decisions explicit.
This is an architecture decision record, not a storage-service recommendation. The useful invariant is modest: the application authorizes a caller and derives the object key; the browser transfers bytes; a server-side verifier decides whether that object becomes visible. A successful command-line request proves very little about the browser path.
Short paths fail loudly.
What should browser direct upload of generated images use for CORS, presigned PUT, and US/EU SaaS?
A browser does not treat a presigned URL as a waiver of its same-origin rules. A cross-origin PUT with Content-Type: image/png commonly triggers an OPTIONS preflight, and the storage endpoint must allow the page origin, PUT, and requested headers before the browser sends the generated image. The signed URL separately authorizes a particular request. Mixing those two checks into one generic “CORS error” is how an afternoon disappears.
Start with the Network panel and identify the first failed request. On the preflight, compare Origin, Access-Control-Request-Method, and Access-Control-Request-Headers with the configured policy. On the upload, preserve the URL, request method, object key, and every signed header exactly. A changed content type belongs to the signing investigation; a rejected OPTIONS belongs to the browser-policy investigation.
Don't open the policy to every origin just to get a green test. That converts a narrow diagnosis into an authorization review and makes later evidence harder to interpret. A short-lived grant narrows time and operation, but it does not establish who may run JavaScript from an allowed origin.
For a US/EU SaaS, assign the tenant to a region before signing and use regional key namespaces that make the assignment auditable. Storage location alone does not settle a transfer analysis: GDPR Chapter V concerns transfers of personal data to third countries, while logs, support access, backups, generation, and replication may cross different boundaries. The legal result depends on the data and organization; engineering should expose the path rather than infer it from a bucket label.
Decision and invariants
The decision is browser-to-storage transfer followed by explicit completion verification for workloads that do not require inspection before durable receipt. The application creates a tenant-scoped key, grants only the required write operation for a short lifetime, and records the intended upload. The client never receives listing, deletion, or arbitrary-key authority. The completion handler verifies that the expected object and metadata correspond to the authorized job, then changes the database record to available.
That final state transition matters more than the upload itself. A PUT says bytes were accepted at a key; it does not say the image passed decoding, moderation, malware scanning, dimensional validation, or tenant binding. Put each required control between receipt and publication, and expire abandoned objects under a lifecycle policy without making them discoverable to another tenant.
The failure boundaries are distinct:
- Application authorization: no valid caller, tenant, or intended key.
- Browser policy: the preflight disallows the origin, method, or headers.
- Signed-request contract: the grant is expired or the sent request differs from its signed form.
- Ingestion verification: the object or metadata cannot be bound to the pending job.
- Publication state: a duplicate completion or retry must not create a second visible asset.
Treating all five as “upload failed” is cheap in a dashboard and expensive in an incident. Record the phase, status, latency, tenant-safe key prefix, and one correlation ID across signing, preflight observation, upload, and completion. Never log the full presigned URL: it carries temporary authority.
Make the pending-upload record an actual state machine, even if the first implementation is a small table. The signer creates pending with the tenant, expected key, intended content type, expiry, and generation-job identifier; the completion endpoint can advance only that exact record after it observes the object and validates the metadata it relies on. A retry after a dropped client response should return the already-recorded result instead of publishing twice. A transfer that finishes after its grant expires may still need a deliberate verification decision, because expiry constrains authorization to start the request, while the object state determines what the verifier can observe. This distinction also gives operations a clean cleanup rule: old pending records and unbound objects are candidates for expiry, visible records are governed by the product's retention policy, and no task needs to guess from a browser toast which branch occurred.
Options considered
| Path | Enforcement point | Good fit | Boundary to accept |
|---|---|---|---|
| Browser presigned PUT plus verifier | Signer before transfer; verifier before publication | Large generated images with checks that can run after receipt | CORS policy and signed headers are separate operational contracts |
| Application proxy | API before storage | Mandatory inline inspection or one synchronous audit point | Application bandwidth, timeouts, and capacity carry every image byte |
| Controlled ingestion service | Dedicated ingress before publication | High-volume pipelines with policy checks and queued recovery | More components, state transitions, and observability ownership |
| Server-side retrieval | Backend transfer worker | A trusted generator is already reachable from the backend | Source authentication, retry limits, and temporary-object cleanup stay server-side |
The catch is that direct browser transfer is not suitable when policy requires rejection before the image reaches durable storage, when the client cannot run the required browser flow, or when a single synchronous audit boundary is an invariant. Use an application proxy or controlled ingestion path in those cases. They are different designs with different failure ownership, not second-class versions of the same design.
Critical-path contract test
The production browser remains the decisive CORS client, but a small Python test can make the storage-facing contract concrete. It exercises the exact URL returned by the signer rather than constructing a look-alike URL, sends the preflight headers a browser would send, and then uses the same content type for the upload.
from dataclasses import dataclass
import requests
@dataclass(frozen=True)
class UploadContract:
url: str
origin: str
content_type: str = "image/png"
def exercise_upload(contract: UploadContract, image_bytes: bytes) -> None:
preflight = requests.options(
contract.url,
headers={
"Origin": contract.origin,
"Access-Control-Request-Method": "PUT",
"Access-Control-Request-Headers": "content-type",
},
timeout=10,
)
preflight.raise_for_status()
allowed_origin = preflight.headers.get("Access-Control-Allow-Origin")
if allowed_origin not in {contract.origin, "*"}:
raise AssertionError(f"unexpected allowed origin: {allowed_origin!r}")
upload = requests.put(
contract.url,
headers={"Origin": contract.origin, "Content-Type": contract.content_type},
data=image_bytes,
timeout=30,
)
upload.raise_for_status()
The test cannot certify browser enforcement, so run browser integration cases from the production origin as well as development. Cover an allowed origin, a rejected origin, an expired grant, a changed signed header, an attempted cross-tenant key, duplicate completion, interrupted transfer, and verifier rejection. For each case, state the expected object state and database state before writing the test. This is where vague requirements become useful operational rules.
Deploy the CORS policy alongside the frontend-origin list and review both together. A proxy centralizes telemetry and inspection, while direct transfer shifts byte volume away from the application tier but leaves the team responsible for CORS, diagnostics, completion idempotency, and orphan cleanup. Your mileage may vary with image size and inspection requirements; the decision should follow the enforcement boundary the team can operate.
Rejected option and valid use case
An application proxy is rejected for the ordinary generated-image path when no synchronous inspection is required before storage and the application tier should not carry image bytes. It becomes the preferred option when inspection-before-storage, transformation during ingress, or one controlled audit point is non-negotiable.
Price should remain a model input, not the architecture argument. Object-storage bills can include stored bytes, operations, retrieval, transfer, retention, failed attempts, and observability data; image size times object count is therefore an incomplete estimate. Model deletion and regeneration behavior in each region, then test recovery and overwrite controls instead of assuming a successful PUT implies recoverability.
Further reading
- https://docs.aws.amazon.com/AmazonS3/latest/userguide/using-presigned-url.html
- https://docs.aws.amazon.com/AmazonS3/latest/userguide/ManageCorsUsing.html
- https://docs.aws.amazon.com/AmazonS3/latest/userguide/PresignedUrlUploadObject.html
- https://aws.amazon.com/s3/pricing/
- https://gdpr-info.eu/chapter-5/
Top comments (0)