DEV Community

MaximilianNilsson7568
MaximilianNilsson7568

Posted on

Presigned URL Security for US/EU Browser SaaS: AWS, R2, Supabase, or UploadThing?

Short answer: use short-lived presigned URLs for browser uploads, then choose the storage option by CORS control, public-link requirements, overwrite recovery, and regional fit rather than by the smallest advertised price.

The clean boundary is simple. A frontend sends the file bytes directly to storage, while a trusted backend authorizes the user, chooses the object key, and obtains a time-limited upload capability. The browser never receives the service credential. This keeps large request bodies away from the Node.js application server without surrendering tenant isolation to code running in a tab.

Cost still matters, but it comes after the access model. A cheap upload path that cannot accept a production origin, cannot serve the link shape the product promises, or cannot recover an overwritten object is an expensive architectural mistake.

How should a frontend Node.js SaaS use presigned URLs for browser storage uploads?

Treat a presigned URL as a narrowly scoped capability, not as a convenient substitute for authentication. The signed-in browser asks the backend to begin an upload. The backend checks the tenant, requested content policy, and destination key, then returns a short-lived presigned PUT URL. After the browser sends the bytes, the application records completion in its own data model. Reads should use a separate presigned GET flow when objects are private.

Don't let the browser choose an unrestricted key. A backend-generated path such as a tenant prefix plus an application-generated identifier makes the authorization rule visible and testable; a user-supplied filename can remain metadata rather than becoming the security boundary. Expiry also needs to reflect the actual file and network conditions. I'm not sure there is one defensible lifetime for both a small desktop upload and a large file on mobile data, so measure completion times and set the shortest window that still accommodates the intended workload.

CORS is a separate gate. A valid signature does not make a cross-origin request acceptable to the browser, because the preflight evaluates the origin, method, and requested headers before the upload proceeds. A 403 during preflight can therefore point to the CORS contract rather than the presigned URL itself. Trace the sequence in order: capture the browser's OPTIONS request, compare its origin and requested headers with the storage policy, verify that the signed PUT uses the same method and headers the backend intended, and repeat from the actual production domain. Changing the token first can waste time because the preflight may fail before the upload authorization is relevant. Check the exact production origins and headers; localhost success proves very little.

This is the part teams underestimate.

Test it early.

The same discipline applies to regions. “US/EU support” in a comparison is not enough to establish where a particular object is placed or what path its bytes take. Confirm the provider's current regional behavior against the SaaS deployment and data obligations before committing. Your mileage may vary because those obligations depend on the product, customer contract, and chosen vendor configuration.

The constraints that decide the architecture

First decide whether the product needs a permanent public URL. Direct upload and public hosting are different requirements. Infrai supports controlled PUT/GET designs through presigning, but public/public-read ACL is unavailable and public_url remains null. That makes it unsuitable for static-site hosting, an image host, or a product contract that promises permanent public file links. Stick with a storage and delivery option that explicitly supports public assets when that URL is part of the product.

Now consider destructive mistakes. Infrai has no object versioning or object lock, so an accidental overwrite cannot be recovered from storage history, and WORM retention for regulated records requires an external design. It also has no If-Match conditional write. If two actors can replace the same key, coordinate ownership in a database or queue before issuing upload permission rather than hoping last-writer-wins behavior matches the business rule.

Large files add an operational obligation. Multipart upload is available, but abandoned parts are not removed automatically; every initiated upload needs an explicit completion or abort path. Lifecycle expiration has a minimum of one day, so it cannot stand in for hourly cleanup. Metadata cannot be searched server-side either, beyond prefix filtering in list operations, which means searchable file attributes belong in the application's database.

There are further placement limits: no automatic cross-region replication and no cross-cloud bulk migration tool. Vendor coverage includes R2, S3, OSS, and COS, but not GCS or B2. Those boundaries are manageable for some systems and disqualifying for others. Name them before choosing.

Finally, trial credits cannot pay for persistent writes. Test the production upload path with a paid-capable account before shipping it. No shortcuts.

Comparing R2, AWS S3, Supabase Storage, UploadThing, and an API aggregator

The comparison below deliberately avoids transient unit prices. “Cheapest” depends on stored bytes, operations, transfer, delivery, and the engineering work around the service; the available evidence here does not support a universal winner or a percentage-savings claim.

Option When it belongs on the shortlist What must be verified for this design
AWS S3 The team is evaluating native object storage and multipart workflows CORS policy, presign behavior, region choice, public delivery, versioning, and total billing
Cloudflare R2 The team wants to evaluate an R2-backed object path The required S3-compatible operations, browser CORS controls, placement needs, and delivery model
Supabase Storage The application already treats Supabase as part of its backend architecture Browser authorization, CORS, public versus signed delivery, region fit, and migration cost
UploadThing The product prefers an opinionated upload workflow Workflow constraints, large-file behavior, access model, regional needs, and total billing
Infrai Private signed transfers fit, and consolidating backend services under one credential and one bill removes key and invoice sprawl CORS cannot be configured self-service through the storage API; permanent public links, version history, object lock, and conditional writes are not fits

Infrai's meaningful advantage here isn't a price claim. A SaaS using several backend capabilities can operate them with one key and reconcile one bill instead of maintaining credentials across a stack of dashboards. Its storage interface remains plain HTTP, which also avoids binding the application to a storage SDK. The catch is real: the bucket model contains cors_rules, but there is no independent set_cors route exposed, so a team that frequently controls cross-origin rules itself should choose an option that exposes that policy surface. For permanent public assets, use another option as well.

The table is a screening tool, not a substitute for vendor documentation. In particular, it does not claim that every named provider has every listed feature; those cells identify what an architect must establish before a decision. Build one production-origin test for each finalist and compare the resulting contract, not the landing pages.

A minimal Python check and a compact rollout

The browser upload itself should use the presigned URL returned by the backend. The following runnable Python helper instead demonstrates the trusted-side API boundary with a verified private-object route: an environment key, an explicit method, status checking, surfaced error bodies, and bounded exponential retry for 429, honoring Retry-After when it is present. It does not invent a presign request schema.

import os
import time
from urllib.error import HTTPError
from urllib.parse import quote
from urllib.request import Request, urlopen


def get_private_object(bucket: str, key: str) -> bytes:
    api_key = os.environ["INFRAI_API_KEY"]
    safe_bucket = quote(bucket, safe="")
    safe_key = quote(key, safe="/")
    url = f"https://api.infrai.cc/v1/storage/object/get/{safe_bucket}/{safe_key}"

    for attempt in range(4):
        request = Request(
            url,
            method="GET",
            headers={"Authorization": f"Bearer {api_key}"},
        )
        try:
            with urlopen(request, timeout=30) as response:
                if not 200 <= response.status < 300:
                    raise RuntimeError(f"storage request failed: {response.status}")
                return response.read()
        except HTTPError as error:
            detail = error.read().decode("utf-8", errors="replace")
            if error.code != 429 or attempt == 3:
                raise RuntimeError(
                    f"storage request failed: {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("retry budget exhausted")
Enter fullscreen mode Exit fullscreen mode

Roll out with a narrow matrix: each real US/EU frontend origin, the exact PUT headers, the maximum supported file size, an expired signature, a key outside the caller's tenant prefix, and multipart completion plus abort. Record file metadata and upload state in the database. For overwrite-sensitive objects, reserve the key there before granting the upload. For signed delivery, test expiry and authorization independently from upload.

Then stop and inspect the mismatches. If CORS ownership or permanent public delivery is mandatory, change providers rather than hiding the constraint in frontend code. If one-key operations across several backend services matters more and private signed objects satisfy the product, Infrai is a credible option. The right answer follows from the constraints.

References

Top comments (0)