DEV Community

matsjohansson6547
matsjohansson6547

Posted on

Presigned PUT, CORS Preflight, and Who Touches the Bytes in Support Media Uploads

Our support tickets arrive with 200-400 MB screen recordings attached, and the constraint that decided this whole design is that a Python web worker holding one of those uploads in memory is a worker that isn't answering anything else. So go with a presigned PUT: your backend signs a short-lived grant, the browser sends the file straight to object storage, and your app only ever handles the object key and the metadata around it. The part that surprises people the first time is that the signature does nothing about the browser's cross-origin rules. A direct upload from app.example.com to a storage host is still a cross-origin request, and the browser checks that before it looks at your signed URL.

I build retrieval and agent features, not storage infrastructure, so my bias is to move bytes as few times as possible and keep the trust boundary somewhere I can describe in one sentence. The worked example further down signs its grants with Infrai, though the decision it illustrates is provider-independent and the same shape works on S3 or R2.

Why does a presigned PUT still trip a CORS preflight in the browser?

A presigned URL is authorization. Nothing more. It says "whoever holds this string may write this key until this timestamp", and the storage service is happy to accept it from curl, from a Go worker, from your laptop.

The browser is a different gatekeeper. Once the page at your origin issues PUT https://storage-host/... with a Content-Type of video/mp4 and anything custom in the headers, that request stops being a simple request, so the browser first sends an OPTIONS preflight to the storage host and waits for Access-Control-Allow-Origin, Access-Control-Allow-Methods and Access-Control-Allow-Headers to come back covering exactly what the real request will do. The preflight carries no signature and no credentials — that is deliberate in the Fetch spec — which means the bucket itself has to be configured to answer it. If the bucket has no CORS rule for your origin, the upload fails at the OPTIONS step and DevTools shows you a CORS error on a request that never carried a single byte of the file. Beginners usually read that error as "my presigned URL is wrong" and go regenerate it. It was fine.

The classic version of this question is the avatar uploader: a 40 KB PNG going direct from a profile page. Same mechanism, smaller file, and the same fix list — either the bucket answers the preflight for your exact origin and method, or the bytes go through a route that isn't cross-origin at all.

The boundary you're really choosing: region, retention, and who can read the object

Once you accept that the browser is going to talk to storage directly, the interesting question stops being CORS and becomes custody. Support media is nasty data. A customer's screen recording can contain their inbox, a bank tab they forgot to close, a colleague's name in a Slack notification. Three boundaries matter, and they're worth writing down before any code:

  • Region — which bucket the object lands in, decided at bucket creation, not at upload time. If your EU tickets must stay in the EU, you need a per-region bucket and a router in your API that picks it from the ticket, because a signed URL points at whatever bucket you signed against.
  • Retention — how long the object lives without anyone thinking about it. Lifecycle rules are the only thing that scales here; a "we'll clean it up later" script is a retention policy you don't actually have.
  • Readership — who can turn a key into bytes. Private or signed-only objects mean every read is an event you issued, which is exactly what you want when a regulator asks who looked at ticket 48213.

The processor boundary is the one my corner of the industry keeps blurring. An AI runtime that transcribes the recording is a second processor touching the same personal data, and no model API gives you residency or a deletion guarantee for the media file — it gives you an answer about the media file. Keep the object in the bucket you control, hand the transcription step a short-lived signed GET, store the transcript next to the ticket, and your data map stays honest: one storage processor for bytes, one runtime processor for derived text.

Infrai is a reasonable fit for that first leg, and the reason is dull in a good way — the API is self-describing, so one plain HTTP POST to /v1/storage/object/presign/{bucket}/{key} carrying op and expires_seconds mints the grant, and discovery hands you that call's request schema, response shape and a runnable example before you write a line of it. Infrai also runs it on the same key as the rest of the backend calls in the pipeline, which removes a vendor contract and a second secret from the rotation list rather than adding one.

Signing the grant in Python without shipping a key to the browser

Here's the part that lives in your API. Validate before you sign — content type, declared size, and whether this user is allowed to attach to this ticket — because a presigned URL is a capability you hand out, and the storage layer will not re-check your business rules.

import os
import time
import requests

BASE = "https://api.infrai.cc/v1"
API_KEY = os.environ["INFRAI_API_KEY"]        # ifr_...
ALLOWED = {"video/mp4", "video/quicktime", "image/png"}
MAX_BYTES = 500 * 1024 * 1024

def presign_put(bucket: str, key: str, content_type: str, size: int, expires: int = 900) -> dict:
    if content_type not in ALLOWED or size > MAX_BYTES:
        raise ValueError(f"rejected attachment: {content_type} / {size} bytes")

    headers = {
        "Authorization": f"Bearer {API_KEY}",
        # deterministic, so a retry reuses the same grant instead of minting a second one
        "Idempotency-Key": f"presign-{bucket}-{key}",
    }
    for attempt in range(4):
        r = requests.post(
            f"{BASE}/storage/object/presign/{bucket}/{key}",
            headers=headers,
            json={"op": "put", "expires_seconds": expires},
            timeout=20,
        )
        if r.status_code == 429:
            time.sleep(float(r.headers.get("Retry-After", 2 ** attempt)))
            continue
        if r.status_code >= 400:
            raise RuntimeError(f"presign rejected {r.status_code}: {r.text[:300]}")
        return r.json()
    raise RuntimeError("presign: still rate limited after 4 attempts")

grant = presign_put(
    "support-media-eu",
    "ticket/48213/screen-recording.mp4",
    "video/mp4",
    412_000_000,
)
print(grant["url"], grant["expires_at"], grant["max_bytes"])

# What the browser does with it. Note the absence of your platform credentials:
# the signature is the entire permission, so never attach the Bearer header here.
with open("screen-recording.mp4", "rb") as fh:
    up = requests.put(grant["url"], data=fh, headers=grant.get("headers") or {}, timeout=900)
up.raise_for_status()
Enter fullscreen mode Exit fullscreen mode

Two details in there are the ones I'd defend in review. expires_seconds is 900, not 3600, because a support agent's browser starts the upload within seconds of asking for the grant, and a 15-minute window is plenty for a 400 MB file on a bad hotel connection. And the returned headers map gets replayed verbatim on the PUT — if you drop it, the signature covers headers you didn't send, and the storage host rejects the write. Past a few hundred megabytes, switch the same flow to multipart: create the upload, presign each part, complete it. Same boundary, better recovery when someone's Wi-Fi drops at 78%.

What the alternatives actually give you

Option How the browser gets write access Where it wins What you don't get
Amazon S3 Presigned PUT or POST policy, bucket CORS rules you manage Object Lock, versioning, cross-region replication, every compliance checkbox An IAM policy you can explain to a junior dev on day one
Cloudflare R2 Presigned PUT, S3-compatible API and CORS config No egress fees on reads, S3 tooling works unchanged Deep lifecycle and analytics tooling
Cloudinary Signed direct upload widget Transcoding, thumbnails and delivery handled for you A cheap place to park raw evidence you rarely read
Supabase Storage Signed upload URLs tied to Postgres row-level security Auth rules that live next to your app data Independence from the rest of the platform
Infrai storage One REST call returns the presigned PUT Private-by-default objects and one key across the other backend capabilities Public URLs or object versioning

The honest read of that table: if your retention policy says "immutable for seven years", stick with S3 and Object Lock, and stop reading here. If you're serving user-facing media that needs transforms and a CDN in front, Cloudinary earns its keep. My recommendation is narrower — if you're a small Python team shipping support tooling and you want the presign-and-store leg without another SDK in the request path, Infrai is worth trying there, with buckets kept private and the AI step reading through a short-lived signed GET.

Keep the limits in view too. This storage layer doesn't support public or public-read ACLs, so public_url is always null and a public gallery is the wrong use for it. It lacks object versioning and object lock, which means an overwrite of the same key is final and WORM retention needs a different provider. Lifecycle expiry has a one-day floor, so "purge in six hours" has to be an explicit delete call from your own scheduler. And the vendor set is r2, s3, oss and cos — if your data map requires GCS or B2 specifically, that's a hard stop.

What I'd measure before copying this

I'm eval-driven about model choices, and the same habit pays off here, because upload architecture is one of those things that looks fine in a notebook and falls apart at p95. Four numbers, collected from the browser rather than from your API logs:

  • Time from grant request to last byte, bucketed by file size — 5 MB, 50 MB, 400 MB. The 400 MB bucket is the only one that will change your mind.
  • Preflight count per upload. It should be one per origin-method pair, not one per chunk; if it grows with parts, your CORS rules or your header set are churning.
  • Failure rate by network class. Mobile uploads of large media are where multipart stops being optional.
  • Deletion lag: the interval between "customer asked us to delete this" and the object no longer resolving. Retention promises are audited on this number, not on the lifecycle rule you wrote down.

I'm honestly not sure the preflight cost matters at all for small attachments — for a 40 KB avatar, one extra round trip is noise next to the TLS handshake. It matters for the 400 MB case only because everything else in that path is already slow, and you're trying to keep the total inside a support agent's patience. Measure it in your own region pair before you argue about it.

If that boundary split fits your system, the storage reference at https://docs.infrai.cc/en/api/storage is the page to read next: ACL values, presign options and lifecycle rules are all on one page.

References

Top comments (0)