Short answer: treat a failed direct avatar upload as two separate decisions: first make the browser's CORS preflight succeed, then make the signed object write satisfy your storage policy. A presigned PUT can authorize a request and still leave the browser unable to send it. For a beginner-facing US/EU application, use a backend relay until you can test the exact frontend origins, storage hostname, method, and headers; direct upload is a throughput choice, not a magic fix for cross-origin policy.
That distinction is the invariant in this architecture decision record. The browser owns the preflight boundary. The object store owns signature and object-write checks. Mixing those layers produces the familiar but unhelpful advice to regenerate the URL until the error goes away.
The decision record: preserve the trust boundary
An avatar is small compared with a video, but it is still untrusted input. The server should select the object key, allowed media types, maximum size, and lifetime of any upload capability. OWASP recommends validating file names, types, and content rather than trusting a browser-supplied name or extension; the avatar's eventual image processing should perform its own checks too.
The two paths are deliberately plain:
| Path | Browser-to-storage behavior | Main strength | Boundary to accept |
|---|---|---|---|
| Backend relay | Browser sends bytes to the application; the application writes the object | One server-side place for authentication and validation | Application bandwidth and request handling carry the upload |
| Direct signed PUT | Application grants one short-lived write; browser sends bytes to storage | Large-file throughput avoids copying bytes through the application | Storage CORS must allow the real origin, method, and requested headers |
For avatar uploads, the relay is often the better first release because it makes the browser's policy boundary disappear from the critical path. For large files, the direct path can be justified, but only after a real browser test from every deployed origin. A region selector labelled “US” or “EU” does not itself establish an allowed origin; app.example and eu.app.example are different origins once their schemes, hosts, or ports differ.
Why does a signed PUT still fail an object-storage CORS preflight?
The browser sends an OPTIONS request before a cross-origin PUT when the request is not CORS-safelisted. The preflight includes Origin, Access-Control-Request-Method, and, when relevant, Access-Control-Request-Headers. The storage endpoint must answer with matching Access-Control-Allow-Origin, Access-Control-Allow-Methods, and permitted headers. If it does not, the browser blocks the actual upload.
The signature comes later in the logic. It proves that a server granted a particular operation, key, and expiry; it does not grant a web page permission to send cross-origin traffic. No signature can override a browser policy response.
This gives us a useful failure map:
| Observation | Layer to inspect first | Correct next question |
|---|---|---|
| Preflight is blocked before a PUT appears | Browser and storage CORS response | Does the response allow this exact origin, PUT, and requested headers? |
| PUT reaches storage but is rejected | Signature or object-write policy | Were the URL, expiry, method, content type, and signed headers used exactly as issued? |
| Upload succeeds but the avatar is unsafe | Application validation and image processing | Did the server inspect bytes, size, type, and storage key before publishing it? |
That table is more valuable than a console error copied into a ticket. A 403 is not proof of a CORS failure, and a CORS console message is not proof that the signature is invalid.
What should a beginner test across browser, origins, and regions?
Test the complete route, not just the URL returned by the signing endpoint. The test matrix should include the production US origin, the production EU origin, local development if it is supported, the actual storage hostname, and a request with the exact headers the browser will send. Test a rejected origin as well; an allow-list that accepts every origin is a security decision, not a convenient default.
The following small Python model keeps the checks explicit in a unit test or deployment gate. It does not pretend to perform a browser preflight; browser behavior must still be verified in an actual browser, because only the browser applies the CORS enforcement step. I'm not sure which wording a particular browser will show, but a 403 after OPTIONS succeeds points to a different layer than a preflight that never permits PUT.
from dataclasses import dataclass
@dataclass(frozen=True)
class PreflightCase:
origin: str
method: str
requested_headers: frozenset[str]
expected: bool
ALLOWED_ORIGINS = frozenset({
"app.example",
"eu.app.example",
})
ALLOWED_METHODS = frozenset({"PUT"})
ALLOWED_HEADERS = frozenset({"content-type"})
def preflight_policy_allows(case: PreflightCase) -> bool:
return (
case.origin in ALLOWED_ORIGINS
and case.method in ALLOWED_METHODS
and case.requested_headers <= ALLOWED_HEADERS
)
cases = [
PreflightCase("app.example", "PUT", frozenset({"content-type"}), True),
PreflightCase("eu.app.example", "PUT", frozenset({"content-type"}), True),
PreflightCase("unknown.example", "PUT", frozenset({"content-type"}), False),
PreflightCase("app.example", "POST", frozenset({"content-type"}), False),
]
assert all(preflight_policy_allows(case) == case.expected for case in cases)
The code is a policy model, not a substitute for configuring the bucket or endpoint. Keep the signed request equally boring: one key, one method, one content type, and a short expiry. Avoid adding an authorization or tracing header to the browser request unless it is required and included in both the signature contract and the CORS allow-list. I've seen teams spend an afternoon changing expiry values when the actual mismatch was a header introduced by a frontend helper.
Then inspect the network panel. Was OPTIONS answered? Did the response contain the exact origin rather than an unrelated one? Did the subsequent PUT use the issued method and content type? Capture these as automated checks in deployment, and log request identifiers server-side without logging signed URLs, which can act as credentials until they expire.
The object-write checks that CORS cannot provide
CORS controls which browser code may read or send a cross-origin response. It does not validate image bytes, prevent an oversized body, choose a safe key, or make an uploaded object public. Those are application and storage-policy responsibilities.
Use an opaque key derived by the server instead of preserving a user filename. Enforce a byte limit before issuing the grant and again when the object is consumed. Validate the declared media type against the bytes you actually receive, isolate uploaded objects from executable content, and publish an avatar only after image processing succeeds. Cache headers also deserve an explicit policy: a user avatar may be cached for a bounded lifetime, while a mutable key can cause stale profile images if cache invalidation is not designed alongside the naming scheme.
The failure boundary matters for rollback. If a direct PUT succeeds and the application later rejects the image, the cleanup operation must be authenticated and observable; a database row marked “pending” is not object deletion. If a user replaces an avatar twice, unique keys plus a current-pointer record make the old object recoverable until a retention policy removes it.
When is the direct path the wrong choice?
The catch is operational, not mysterious: direct browser upload is not suitable when you cannot control the storage CORS policy, when every byte must pass through server-side inspection before storage, or when a product requires a simple public-read URL model that the chosen private-object design does not provide. Use the backend relay in those cases, even if it handles less throughput.
Direct upload is also a poor fit when the team cannot observe preflight responses from both US and EU deployments, has no plan for abandoned objects, or treats a signed URL as a permanent permission. A relay is easier to reason about for a first avatar feature. Your mileage may vary for native mobile clients: a native client does not inherit browser CORS enforcement, but it still needs the same authentication, size, type, and lifecycle controls.
The rejected option in this record is “give the browser a broad storage credential and let it figure out the rest.” It removes a little setup while expanding the blast radius of credential leakage and making origin policy harder to audit. A short-lived, key-scoped grant is preferable when the browser path has a measured throughput benefit; otherwise, keep the write behind the application and spend the complexity budget on validation and observability.
Top comments (0)