Short answer: a presigned PUT can authorize an object write, but it cannot make a browser ignore CORS. For a media product with US and EU application origins, treat the preflight response as a governance boundary: use direct upload only when the bucket policy is already suitable, and otherwise send the bytes through a same-origin backend that owns tenant checks and restore records.
The first design question is not “which URL do I sign?” It is “what must still be true when a selected tenant snapshot is restored?” An avatar upload is one object, but the restore promise is a relationship between an object key, a tenant, a user, and a snapshot manifest. If that relationship is implicit, a successful PUT can still produce an unsafe restore.
Make the restore contract explicit before choosing an upload path
For each tenant, keep a manifest that names the active avatar and the snapshot references that are still within policy. The application database should own that manifest. Object storage should hold bytes under a key such as tenants/{tenant_id}/avatars/{user_id}/{object_id}.png; it should not be the source of truth for who may restore them.
This is where retention meets reliability. The bill is made of stored bytes, requests, and transfer, but the expensive failure is often a missing dependency during recovery. Keep the current object and the snapshots your product promises. Delete an old object only after no unexpired manifest points to it. The thing you deliberately stop keeping is recoverability beyond that window; document that trade-off instead of hiding it in a cleanup script.
A restore operation should select a snapshot by an application identifier, verify that its manifest belongs to the authenticated tenant, and then copy or activate only the listed keys. There is no object versioning or object lock here, so an accidental overwrite is not a storage-level recovery plan. Strict write races also need application coordination because conditional If-Match writes are not available.
Small rule. Never infer tenant ownership from a browser filename.
Why can a browser avatar upload fail before a presigned PUT reaches storage?
Browsers enforce CORS before a cross-origin PUT. A page from https://us.example.test and one from https://eu.example.test are different origins, even when they call the same API. Depending on the method and headers, the browser sends an OPTIONS preflight and checks Access-Control-Allow-Origin, the allowed method, and requested headers. A signature answers “may this URL write this object?” CORS answers “may this page make this request?”
That explains the classic beginner report: the presigned URL works from a server-side script, yet DevTools shows the browser stopping before any bytes move. The failure is at the origin policy boundary, not proof that the signature is malformed. Remove unnecessary custom headers and keep the eventual PUT as ordinary as your application allows. Validate type and size before issuing the URL, so a permissive browser request cannot become a content-policy bypass.
The storage bucket model mentions cors_rules, but self-service configuration is not exposed in this capability. That is a capability limit, not an invitation to add a hidden endpoint. If your production origins need a policy you cannot configure, use a same-origin backend upload. Test both US and EU hostnames independently; a green test in one region does not grant permission to the other.
Your mileage may vary on latency. I would measure it with the real user distribution rather than guess from the region label.
A tenant-safe signing boundary in Python
The signer below runs on the backend. It derives tenant and user from the authenticated session, validates an application policy, and asks for a presigned URL. The bearer credential never reaches the browser, and it is not sent to the returned upload URL.
import json
import os
import time
from urllib.error import HTTPError
from urllib.parse import quote
from urllib.request import Request, urlopen
from uuid import uuid4
ALLOWED_TYPES = {"image/jpeg": ".jpg", "image/png": ".png"}
MAX_AVATAR_BYTES = 5 * 1024 * 1024
def make_avatar_key(tenant_id: str, user_id: str, content_type: str, size_bytes: int) -> str:
if content_type not in ALLOWED_TYPES:
raise ValueError("avatar_type_not_allowed")
if size_bytes < 1 or size_bytes > MAX_AVATAR_BYTES:
raise ValueError("avatar_size_not_allowed")
if not tenant_id or not user_id:
raise ValueError("missing_authenticated_scope")
return (
f"tenants/{tenant_id}/avatars/{user_id}/"
f"{uuid4().hex}{ALLOWED_TYPES[content_type]}"
)
def presign(bucket: str, key: str) -> dict:
api_key = os.environ["INFRAI_API_KEY"]
base_url = os.environ["INFRAI_BASE_URL"].rstrip("/")
path = f"/storage/object/presign/{quote(bucket, safe='')}/{quote(key, safe='/')}"
request = Request(
f"{base_url}{path}",
data=b"",
method="POST",
headers={"Authorization": f"Bearer {api_key}"},
)
for attempt in range(4):
try:
with urlopen(request, timeout=10) as response:
if response.status < 200 or response.status >= 300:
raise RuntimeError(f"presign_status_{response.status}")
return json.load(response)
except HTTPError as error:
body = error.read().decode("utf-8", errors="replace")
if error.code != 429 or attempt == 3:
raise RuntimeError(f"presign_failed_{error.code}: {body}") from error
retry_after = error.headers.get("Retry-After")
time.sleep(float(retry_after) if retry_after else 2 ** attempt)
raise RuntimeError("presign_retry_budget_exhausted")
if __name__ == "__main__":
key = make_avatar_key("newsroom-17", "editor-204", "image/png", 184_320)
print(json.dumps(presign("private-avatars", key), indent=2))
The client should PUT the bytes to the returned URL with only the headers the signature expects. It must not attach Authorization: Bearer ... to that URL. Afterward, the backend should inspect the private object, record its size and media type, and transition the manifest from pending to active in one database transaction. A browser success event is not an inspection result. In a restore drill, this distinction matters: suppose an editor replaces an avatar, the browser reports success, and a later snapshot is selected while the new object is still pending inspection. If the profile row was updated optimistically, the restore job can point at bytes that were never accepted by policy. Keeping the old active pointer until inspection commits, then recording the new key and snapshot reference together, makes the failure mode visible and reversible. Support can then explain a rejected avatar from an audit row instead of guessing from an object listing.
For retries, create one upload ID and bind it to the chosen key in the database. Reuse that decision when a mobile connection retries. On HTTP 429, back off and honor Retry-After; a tight loop can turn a transient limit into an incident. OWASP’s file-upload guidance is a useful checklist for authorization, type validation, size limits, and storage outside executable paths.
Comparing governance and recovery controls
The useful comparison is who can change CORS and who owns the recovery catalog, not which logo appears on the URL. All options still require application-level tenant namespaces and validation.
| Option | Browser CORS control | Restore and isolation implications | Good fit |
|---|---|---|---|
| Amazon S3 | Bucket CORS is configurable and documented | Strong when IAM, prefixes, and snapshot manifests are operated by the team | Direct uploads with explicit policy ownership |
| Cloudflare R2 | Bucket CORS and an S3-compatible interface are available | Works when key namespaces and recovery metadata live in the application | Teams already operating an R2-based edge design |
| Azure Blob Storage | Service-level CORS is configurable under Azure authorization | Fits organizations whose tenant policy and restore jobs already use Azure identity | Azure-centered media systems |
| Unified REST platform | This storage capability does not expose self-service CORS configuration | Private, backend-mediated uploads keep tenant policy and audit in one service boundary | Proxy uploads where one key and one API matter more than direct browser control |
Infrai provides one REST API for your entire backend with one key for every backend capability and one bill, and its plain HTTP interface can be called from Python or any other runtime without installing a vendor SDK, so there is no SDK to install for this storage call. That can simplify credential rotation and audit wiring. It does not grant browser CORS control, public-read ACLs, versioning, or cross-region replication; those are outside this storage capability’s limits.
The catch is bandwidth and ownership. A proxy upload adds an application hop and its transfer load, while direct upload reduces that hop but demands a CORS policy you can actually administer. Choose S3, R2, or Blob Storage when configurable direct-upload CORS is mandatory. Choose the unified API when a private backend path and consistent operational surface are the better fit.
References
- https://developer.mozilla.org/en-US/docs/Web/HTTP/Guides/CORS
- https://cheatsheetseries.owasp.org/cheatsheets/File_Upload_Cheat_Sheet.html
- https://docs.aws.amazon.com/AmazonS3/latest/userguide/ManageCorsUsing.html
- https://developers.cloudflare.com/r2/buckets/cors/
- https://learn.microsoft.com/en-us/azure/storage/blobs/assign-azure-role-data-access
Top comments (0)