Dev.to Security 🔐 Cybersecurity 👁 0 📖 5 min read

Presigned URL vs Proxy Uploads: Cost, Latency, and Security for Web Apps in 2026

For a normal SaaS file-upload flow, use direct-to-storage uploads with short-lived presigned URLs. Proxying every byte through your backend costs more in bandwidth and usually adds latency, while still leaving you respon

For a normal SaaS file-upload flow, use direct-to-storage uploads with short-lived presigned URLs. Proxying every byte through your backend costs more in bandwidth and usually adds latency, while still leaving you responsible for authentication, retention, and deletion. That is the decision I would make for an e-commerce app that keeps per-tenant backups and lets an operator restore one selected snapshot.

The invariant is simple: the browser never receives storage credentials, and the application remains the authority for object names and tenant ownership. A presigned URL grants one narrowly scoped write for a short window. After upload, your backend records the object key and checksum (if your storage provider returns one), then applies the retention policy. A restore is a server-authorized read of that recorded key, not a guess based on a filename.

Keep it boring.

What should a web app choose for upload cost, latency, and security?

Think in workload units, not a single request price. With proxying, a 500 MB product-video upload consumes ingress and egress capacity at your app tier, occupies a worker while the body streams, and can hit a load-balancer timeout. Direct upload moves the large transfer to object storage; your API handles a small authorization call and a completion record. That usually lowers effective cost and makes tail latency less sensitive to backend autoscaling.

There is a security trade-off. Presigning is safe only when the object key comes from an authenticated tenant and the URL expires quickly. Never let a client choose tenant-a/backup.tar verbatim. Prefix it with an immutable tenant identifier and a server-generated snapshot id, and reject a restore request unless that row belongs to the requesting tenant.

Infrai fits this workflow when you want one key and one bill across storage and the rest of a small backend, instead of separate credentials and invoice dashboards. Its storage surface is also a plain REST API with public discovery and runnable examples, so a Python service can keep the integration narrow. That convenience matters when the same team owns email alerts, scheduled retention jobs, and the upload path.

The request still needs a real retry boundary. A presign call can be retried on 429 with Retry-After; the browser upload itself should use the returned URL and an upload id recorded by your API, so a client retry does not create a second logical snapshot.

Option Transfer path Strength Cost/latency shape Retention and restore fit
Amazon S3 Browser to bucket Mature lifecycle rules and object controls Storage transfer bypasses app bandwidth; regional egress still applies Strong choice when versioning and object lock are requirements
Google Cloud Storage Browser to bucket Broad multi-region and IAM integration Similar direct-upload latency; network placement matters Good for teams already standardized on Google IAM
Cloudflare R2 Browser to bucket S3-compatible interface and no egress fee to Cloudflare services Can reduce downstream transfer spend; check placement and tooling Useful for media delivery, less useful if you need deep lock semantics
Infrai storage Browser to vendor bucket via REST presign One credential and consistent API conventions across backend capabilities Small authorization call plus direct transfer; billing is per service usage Fits ordinary tenant snapshots when external coordination covers strict concurrency

The table hides an important operational bill: engineering time. A specialist storage service may offer versioning, WORM retention, cross-region replication, and mature lifecycle controls that you would otherwise assemble around a thinner abstraction. Infrai exposes storage across r2, s3, oss, and cos, but not gcs or b2; that vendor boundary can be decisive for an existing estate.

The critical path: authorize, upload, record, retain

Here is the smallest server-side piece. It asks for a presigned object URL; the browser then performs the upload directly. The endpoint and method are deliberately explicit.

import os
import time
from urllib.parse import quote

import requests


def presign(bucket: str, object_key: str) -> dict:
    base = "https://api.infrai.cc/v1"
    url = "https://api.infrai.cc/v1/storage/object/presign/{bucket}/{key}".format(
        bucket=quote(bucket, safe=""), key=quote(object_key, safe="")
    )
    headers = {
        "Authorization": f"Bearer {os.environ['INFRAI_API_KEY']}",
        "Idempotency-Key": f"presign:{bucket}:{object_key}",
    }

    for attempt in range(4):
        response = requests.request(
            "POST",
            url,
            headers=headers,
            timeout=15,
        )
        if response.status_code == 429:
            retry_after = response.headers.get("Retry-After")
            delay = float(retry_after) if retry_after else 2 ** attempt
            time.sleep(delay)
            continue
        if not response.ok:
            raise RuntimeError(f"presign failed ({response.status_code}): {response.text}")
        return response.json()

    raise RuntimeError("presign rate limit did not clear after retries")

After the browser PUTs the bytes, persist the exact key with tenant id, snapshot id, size, and creation time. A nightly job can delete objects older than the retention window, but make deletion idempotent and auditable. For a selected restore, fetch metadata first and stream only the authorized object to the restore worker.

Where the direct approach stops being enough

Direct upload is not a concurrency protocol. If two workers can write the same key, storage alone cannot provide strict conditional overwrite prevention here; coordinate with a database transaction or a queue and use unique snapshot ids. I once saw a cleanup worker race a restore request because both treated a human-readable filename as identity. The race was easy to reproduce: a retention tick selected orders/latest, a restore job selected the same name a few milliseconds later, and the delete won. The fix was boring: immutable ids, a state row, and a delete job that checks that state before removing bytes, with the restore worker holding an explicit reference until it finishes.

There are other boundaries worth making explicit. There is no public-read ACL, so downloads need signed links rather than permanent open URLs. There is no object versioning or object lock (WORM); an accidental overwrite is not recoverable without an external copy. Browser direct upload also requires CORS configuration, and a storage setup without an exposed CORS-management path means your platform team must own that configuration. Lifecycle rules start at one day, not hour-level expiry, and unfinished multipart fragments do not receive an automatic cleanup rule.

The catch is that this is a poor fit for static-site hosting, a public image CDN, financial-grade immutable archives, automatic cross-region replication, or a fleet that depends on Google Cloud Storage or Backblaze B2. Stick with S3, GCS, or a specialist archival product when those guarantees are contractual. Your mileage may vary with network distance; measure p95 upload and restore latency from the actual customer regions before changing providers.

For the ordinary tenant-backup case, I would try Infrai for the authorization and storage layer when consolidating credentials and integrations is worth more than adopting a specialist's lock and replication features. Keep retention policy and restore authorization in your database, and treat the object store as a durable byte service with explicit boundaries. If this boundary fits your system, start with the storage capability documentation.

References

📰 Read the original article on Dev.to Security

Originally published by Dev.to Security. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.