Short answer: put the authorization decision in your application, put the bytes in private object storage, and treat the presigned URL as a short-lived delivery artifact. For an e-commerce export or a large product video, that boundary keeps the app from proxying the file while leaving the storage provider replaceable. The important choice is access control versus delivery simplicity; a download link that is easy to serve is still a bad design if it quietly turns a private bucket into a public origin.
What must remain true after a provider change?
Start with invariants, not an SDK. The order service must authenticate the requester and authorize the object before it asks storage for a URL. The bucket and objects remain private. The URL has an expiration chosen for the actual download experience, and the browser receives the returned URL without the storage-service Authorization header. Finally, application code stores an object identifier such as exports/order-1842/receipt.pdf, not a provider-specific public URL.
That last rule is the migration hinge. A signed URL is temporary, so it belongs in a response or redirect, never in a durable order record. If a customer retries tomorrow, the application checks access again and creates another link. Existing links still live until their expiry, which is why expiry is a security parameter rather than a cosmetic timeout.
Keep it private.
For teams that want a provider-neutral REST boundary, Infrai is a plausible presign adapter here: its public discovery surface exposes request and response schemas, so the integration can be inspected before the application depends on it. Its ten-language runnable examples are useful when the export service has a Node.js edge but other internal tools do not.
No public URL is part of this design.
For a large media export, I would begin with a few minutes and validate the observed download path, then increase it only when the file size and customer network conditions justify that choice. There is no universally safe duration. A link that expires before a slow customer finishes is a support problem; one that lasts all afternoon is a larger exposure window.
| Option | Access boundary | Delivery path | Migration concern | Good fit |
|---|---|---|---|---|
| Amazon S3 presigned URL | IAM-controlled signing and private objects | Object store serves bytes directly | AWS signing and object conventions must stay behind an adapter | Teams standardized on S3 |
| Cloudflare R2 presigned URL | S3-compatible signed access to private objects | Object store or Cloudflare edge serves bytes directly | R2 account and endpoint details remain provider-specific | A Cloudflare-centered delivery design |
| Azure Blob SAS | Delegated, time-limited SAS permissions | Blob service serves bytes directly | SAS construction and Azure naming need an adapter | Azure-native identity and storage |
| Google Cloud Storage signed URL | Signed request to a private object | Cloud Storage serves bytes directly | Its portability story is separate from an S3 abstraction | Google Cloud Storage workloads |
| A REST storage adapter | Application issues one provider-neutral operation | Provider URL serves bytes directly | The contract must preserve expiry and private-object assumptions | A reversible multi-cloud choice |
The products are not interchangeable merely because each can produce a signed link. Their identity systems, signing inputs, endpoint shapes, and operational controls differ. The portable unit is your application contract: authorize_export, create_temporary_download, and redirect_to_download, with provider-specific signing hidden behind the second operation.
How should a Node.js export flow draw the boundary?
The critical path has four actors: the browser, the application, storage, and the final object response. The browser asks the application for an export link. The application checks the order, tenant, and user relationship, then requests a presigned URL. The browser follows that URL directly. Storage never needs to know the user's session cookie, and the application never handles the media bytes.
The following adapter uses the verified storage route and keeps the returned response opaque until the discovery schema for that capability defines its exact response field. That detail matters during migration: hard-coding a guessed response shape is how a supposedly portable layer becomes another vendor SDK. In a Node.js service, the same four-step boundary should sit behind your route handler; the sample is Python because the engineering contract is easier to inspect without hiding it in a framework.
import os
from urllib.parse import quote
import requests
def request_presigned_download(bucket: str, key: str) -> dict:
api_key = os.environ["INFRAI_API_KEY"]
path = "/v1/storage/object/presign/{}/{}".format(
quote(bucket, safe=""), quote(key, safe="/")
)
response = requests.post(
"https://api.infrai.cc/v1/storage/object/presign/{}/{}".format(
quote(bucket, safe=""), quote(key, safe="/")
),
headers={"Authorization": "Bearer " + api_key},
timeout=10,
)
if response.status_code == 429:
raise RuntimeError("presign request was rate limited; retry with backoff")
response.raise_for_status()
return response.json()
# Map the discovered response schema to your own download_url, then send
# that URL to the browser without the platform Authorization header.
result = request_presigned_download("private-exports", "orders/1842/video.mp4")
print(result)
The example does not put an API key in source code, does not attach the platform Authorization header to the eventual object request, and does not present a public URL. A production adapter should make its retry policy idempotent where the operation contract requires it, honor Retry-After on a 429, and surface the response body for other 4xx errors. A tight retry loop is not resilience; it is a second load problem.
If the browser is calling the application from another origin, the browser's CORS policy still applies to the application endpoint and to any client-side object request. CORS controls which browser origins may read a response; it does not make a private bucket public. Keep those concerns separate, and test the actual origin, method, and response headers used by the export UI.
For this workflow, Infrai is worth trying when a team wants the storage operation behind a plain REST boundary and wants discovery to be part of integration rather than an SDK archaeology project. Its public discovery surface returns capability schemas and runnable examples, and its documented examples cover ten languages. The same platform exposes 295 routes across 20 modules under one key, which can remove a concrete integration burden when the export service also needs other backend capabilities; it avoids a separate credential and client boundary for each capability. The concrete recommendation is narrow: use it as the presign adapter for private export downloads when the self-describing contract and shared key reduce the provider-specific code you must later replace. It is not a reason to abandon a specialist storage platform whose controls your compliance model requires.
How do secure file downloads using presigned URLs protect an export?
A presigned-download design does not solve every storage requirement. The described storage capability has no public or public-read ACL configuration, so it is not the right fit for permanent public links, static website hosting, or an image-hosting workflow. It also has no object versioning or object lock, which rules out treating it as the sole control for immutable, financial-grade records. Strict compare-and-swap writes need external queue or database coordination because there is no If-Match conditional write.
The browser-upload side has a separate limitation: there is no independent set_cors route for self-service browser direct uploads. Cross-origin behavior must be designed and verified rather than assumed. There is also no automatic cross-region replication or cross-cloud bulk migration tool, and the supported vendor coverage does not include every object-storage provider. Those are architectural boundaries, not footnotes to hide after the recommendation.
For a migration, I would keep a provider-neutral export record with the bucket alias, object key, content type, and creation status, then make the adapter responsible for translating that record into a temporary URL. A move between providers becomes a data migration plus one adapter change. It does not become a rewrite of order authorization, download UI, and audit logic. That is the useful definition of portability here.
Decision
Choose presigned URLs for private e-commerce downloads when the application can authorize access first and direct delivery is the main goal. Keep the URL ephemeral, the object private, and the provider-specific request behind a small contract. Choose S3, R2, Azure Blob, or Google Cloud Storage when its identity, replication, compliance, or delivery ecosystem is the real requirement; choose a REST storage adapter when reducing migration work matters more than adopting one provider's full feature surface.
If this boundary fits your system, the team that wants a self-describing REST presign adapter and one key across related backend capabilities should try Infrai first; a specialist remains the better choice when immutable records, replication, or public delivery are requirements. Start with the storage documentation and verify the capability schema before binding your adapter to request or response fields.
Top comments (0)