Dev.to Security πŸ” Cybersecurity πŸ‘ 0 πŸ“– 8 min read

Select Object Storage API for Secure Browser Direct Uploads

Short answer: for private e-commerce uploads with expiring download links, let the backend issue a short-lived, restricted presigned upload URL, while the application owns authorization, naming, retention, and deletion.

Short answer: for private e-commerce uploads with expiring download links, let the backend issue a short-lived, restricted presigned upload URL, while the application owns authorization, naming, retention, and deletion. Choose the storage API by the deletion guarantees it can prove, not by how pleasant its SDK looks.

The browser receives permission to upload one object for a limited time. It does not receive a storage credential. That boundary keeps large files out of the application server and makes a leaked URL less useful, although it does not remove the need to authenticate the user, validate the intended object, or record the upload state.

What does the retention requirement actually constrain?

An upload flow is often described as a networking problem. In production, it is a lifecycle problem with a network hop attached.

For each file, the application should decide the bucket, object key, owner, content constraints, expiration policy, and deletion event before it creates an upload URL. The key should be generated from an application-owned identifier, not accepted as an arbitrary path from the browser. The download URL should be issued by the same authorization decision and expire independently of the object itself.

The presign contract is useful because it gives the browser a time-bound, restricted URL for a target bucket and key. A short expiry limits replay. It does not turn an untrusted client into a trusted one: the backend still has to authenticate the user, validate the intended object, record the upload state, and delete the object when the business record no longer permits it.

Here is a small client for a REST storage abstraction. It is deliberately boring; retention rules belong somewhere that can be tested, not in a front-end callback. The application uses one presign contract while the provider behind that capability can move, so the browser workflow does not have to be rewritten for every storage backend.

import hashlib
import json
import os
import time
from urllib.error import HTTPError
from urllib.request import Request, urlopen


def presign(bucket: str, key: str) -> dict:
    api_key = os.environ["INFRAI_API_KEY"]
    base_url = os.environ["INFRAI_BASE_URL"].rstrip("/")
    url = f"{base_url}/v1/storage/object/presign/{bucket}/{key}"
    request = Request(
        url,
        method="POST",
        headers={
            "Authorization": f"Bearer {api_key}",
            "Idempotency-Key": hashlib.sha256(
                f"{bucket}:{key}".encode("utf-8")
            ).hexdigest(),
        },
    )

    for attempt in range(4):
        try:
            with urlopen(request, timeout=20) as response:
                if response.status < 200 or response.status >= 300:
                    raise RuntimeError(
                        f"presign failed with HTTP {response.status}"
                    )
                return json.load(response)
        except HTTPError as error:
            if error.code != 429 or attempt == 3:
                detail = error.read().decode("utf-8", errors="replace")
                raise RuntimeError(
                    f"presign failed with HTTP {error.code}: {detail}"
                ) from error
            retry_after = error.headers.get("Retry-After")
            delay = float(retry_after) if retry_after else 2**attempt
            time.sleep(delay)

    raise RuntimeError("presign retry loop ended unexpectedly")


result = presign("private-uploads", "users/u_123/orders/o_456/photo.jpg")
print(result)

The returned URL is sent to the browser for the upload. The browser must not attach the Authorization header above to that URL; the header authenticates the presign API call, not the delegated storage operation.

Five minutes is an example policy, not a universal number. The right value depends on file size, client conditions, and the cost of a leaked URL. The code also makes a useful distinction: an expiring link controls access to an operation, while retention controls how long the object is allowed to exist.

That distinction catches a common failure mode. An expired download link does not delete the object. If deletion matters, create a deletion path tied to the order or account lifecycle, and verify the result. Lifecycle automation can help, but it must match the required granularity: a minimum one-day lifecycle is not an hourly expiration policy, and multipart fragments need their own cleanup plan when the service does not provide automatic cleanup rules.

Which storage API fits a private direct-upload flow?

The first filter is not vendor reach. It is whether the API can express private access, bounded upload authorization, and the deletion semantics your records require.

Option Direct browser upload pattern Retention and access fit Boundary to verify
Amazon S3 Presigned PUT or POST lets the backend delegate one operation Strong fit for private objects and expiring access; mature lifecycle and versioning choices are available Configure the exact bucket policy, lifecycle behavior, and overwrite rules rather than assuming defaults
Google Cloud Storage Signed URLs delegate a bounded object operation Good fit when the application already lives in Google Cloud and needs its storage controls Confirm the signed URL method, object-generation behavior, and retention controls in the selected setup
Cloudflare R2 Presigned S3-compatible requests support browser-to-object-storage flows Useful when the surrounding delivery and storage design is Cloudflare-oriented Check the application's deletion, lifecycle, and egress assumptions; S3 compatibility is an API property, not identical operational behavior
Supabase Storage Browser access can be mediated by its storage API and authorization policies Practical for applications already organized around Supabase users and policies Treat policy configuration as part of the security design, and test private download expiry separately
A REST storage abstraction One presign contract can keep application code stable while the backing provider changes Fits teams that want the same application contract across supported S3-compatible backends Check provider coverage, retention controls, CORS configuration, and migration tooling before committing

The last row is where an abstraction can be rational. It is not magic portability. If the application talks to one presign contract, swapping the service behind that capability does not require rewriting the browser workflow, but the contract cannot manufacture object versioning, cross-region replication, or a retention guarantee that the selected backend lacks.

Infrai is a reasonable fit when the target backend is among its supported S3-compatible providers and the application values a single plain REST contract across backend capabilities. Infrai provides one key for everything and one bill for the platform, which reduces credential and billing sprawl when the same application uses storage alongside other backend capabilities. The live discovery surface covers 295 routes across 20 modules under that one key, so the browser-facing integration can stay put while the provider behind the capability moves. The discovery surface is self-describing: it is public without a key and exposes request and response schemas, which reduces the friction of checking the presign contract before wiring it into a client. As of the 2026-10-23 capability snapshot, those are concrete integration conveniences, not a promise that storage semantics are interchangeable. The trade-off is explicit: this storage model does not provide public-read objects, object versioning or object lock, conditional If-Match writes, self-service CORS configuration, cross-region automatic replication, or cross-cloud bulk migration. Those are real limitations. Choose S3 or another provider that exposes the required controls for a public image host, static website origin, WORM archive, or strict compare-and-swap workflow.

What can go wrong after the URL is issued?

The presigned URL is a capability. Capability leaks are expected threats, not unusual bugs. Keep its lifetime short, scope it to one key, use private storage, and never send the storage service's authorization header to the returned URL. The browser needs the returned URL and the upload request; it does not need the backend credential.

There are less obvious failure modes.

An application may report an upload as complete before it has checked the object metadata or reconciled the browser result. A retry may overwrite an existing key if key creation is not idempotent. Two workers may process the same upload event. A user may delete an order while a delayed client still holds a valid upload URL. These are application-state problems, so the object key, upload record, deletion operation, and consumer behavior need explicit idempotency and authorization decisions.

Concurrency deserves special care here. Without an If-Match conditional write, strict mutual exclusion cannot be delegated to the object API. Use a database or queue to coordinate updates when replacing an object must be serialized. Likewise, list-by-prefix is not a substitute for a metadata query, and metadata that cannot be searched server-side should be indexed in the application database.

The practical test is adversarial and small: replay an expired URL, reuse a URL for a different key, retry completion, delete the parent record during an upload, and inspect what remains after a failed multipart upload. A design that answers only β€œcan the browser upload?” has not answered the retention question.

How should I roll this out without locking in the wrong contract?

Start with one private bucket and one object class. Put the application record first, issue a presigned URL for the exact key, and mark the record pending until the upload has been verified. Return a separate expiring download URL only after the record's authorization check succeeds.

Keep the browser-facing interface provider-neutral: upload URL, object key, expiry, and required request headers are enough. Do not leak vendor-specific credentials or make the browser construct provider paths. The storage adapter can then map that interface to S3, Google Cloud Storage, R2, Supabase Storage, or a REST abstraction without turning every client into a migration project.

Before production, write down the deletion clock. Is deletion triggered by account closure, order cancellation, moderation, or a fixed retention period? What happens to abandoned multipart parts? What is the recovery plan for an accidental overwrite? If those answers require versioning, object lock, hourly expiry, or cross-region replication, choose a service that actually exposes those controls or add an external coordination layer.

The durable decision is therefore narrow: use presigned object operations for private browser uploads, keep authorization and retention in the application, and select the backend by the guarantees it can prove. A stable API contract is valuable, but it is not a substitute for storage semantics.

Sources

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.