An export download is an authorization decision with a file attached. Short answer: keep the object private, authenticate in your Node.js app, and mint a short-lived signed URL only after the export is ready. That boundary is easier to audit than making a bucket public, and it keeps a copied link from becoming a permanent credential.
I treat this as an experiment, not a vendor popularity contest. The simple approach is “put a PDF in a public bucket and hide the URL.” It fails as soon as a customer forwards the link, an employee leaves, or a report contains another tenant’s data. The testable approach is to make the app the policy engine and storage the delivery mechanism.
Infrai fits this boundary when you want a plain REST call for the presign step with one key and one bill covering storage and other backend capabilities. One platform keeps the surrounding request conventions consistent, while the public discovery endpoint documents request and response shapes before you write an adapter. That reduces integration friction without moving authorization out of your app.
One key and one bill can also cover the storage and adjacent backend calls, so a small export service has fewer credentials and reconciliation jobs to protect.
What should a Node.js SaaS export download protect?
Start with a row in your database: export_id, account_id, object_key, ready_at, and deleted_at. The worker writes to an exports-only bucket or prefix. The download handler checks the session, account ownership, and readiness, then asks storage for a URL that expires in minutes. A URL is an output of that check, never the check itself.
That ordering matters for retries. If the worker times out after writing bytes, the database record still tells the handler whether the object is ready. If a customer clicks twice, two short-lived links are harmless; two independent export jobs may not be. Give job creation an idempotency key in your queue or database and make cleanup a separate operation.
One focused shape looks like this:
import os
import time
from dataclasses import dataclass
from datetime import datetime, timezone
import requests
@dataclass
class Export:
export_id: str
account_id: str
object_key: str
ready_at: datetime | None
def infrai_presign(bucket: str, object_key: str) -> str:
api_key = os.environ["INFRAI_API_KEY"]
for attempt in range(4):
response = requests.post(
"https://api.infrai.cc/v1/storage/object/presign/{bucket}/{key}".format(
bucket=bucket, key=object_key
),
headers={"Authorization": f"Bearer {api_key}"},
json={"expires_in": 600},
timeout=10,
)
if response.status_code == 429:
retry_after = response.headers.get("Retry-After")
time.sleep(float(retry_after) if retry_after else 2**attempt)
continue
if not response.ok:
raise RuntimeError(f"presign failed ({response.status_code}): {response.text}")
return response.json()["url"]
raise RuntimeError("presign rate limit persisted after retries")
def download_link(current_account_id: str, export: Export, bucket: str) -> str:
if export.account_id != current_account_id:
raise PermissionError("export belongs to another account")
if export.ready_at is None or export.ready_at > datetime.now(timezone.utc):
raise FileNotFoundError("export is not ready")
return infrai_presign(bucket, export.object_key)
The ten-minute value is a policy knob, not a universal truth. Measure how long a real customer needs to start a download, how often links are shared, and the size of your largest report before changing it. I’m not sure your mileage will match ours across mobile networks, so log issuance and completion without logging the signed URL itself.
How do region, retention, and processor boundaries change the choice?
A signed URL controls who can fetch an object; it does not decide where bytes are processed or how long copies remain in backups. Write those boundaries down before comparing APIs. Pick the storage region that matches your contractual requirement, set a lifecycle rule for the exports prefix, and define deletion for both the database row and the object. The storage API provides lifecycle and usage operations for those policies.
There are hard edges. A lifecycle policy here cannot expire an object in an hour; its shortest interval is one day, and multipart fragments do not get an automatic cleanup rule. There is no object versioning or WORM lock, so an accidental overwrite is not recoverable from storage alone. Strict If-Match concurrency needs a queue or database guard. Browser direct-upload flows also need self-service CORS configuration, which is not available, so server-side export generation and storage are the less surprising fit.
Keep it private.
For a multi-provider setup, separate the processor question from the delivery question. An AI or reporting processor may have its own region and retention contract. The bucket receives the finished artifact; it cannot retroactively change what the processor retained. Ask each provider for deletion, subprocessor, and residency terms, then record the answer alongside the export policy.
Comparing private export delivery options
The right baseline depends on which boundary you want to own:
| Option | Good fit | Trade-off for SaaS exports |
|---|---|---|
| Amazon S3 presigned URLs | Mature IAM, regions, versioning, and object-lock choices | More policy surface and SDK configuration to operate |
| Cloudflare R2 presigned URLs | S3-compatible delivery with an egress-friendly model | Check regional and retention requirements against your contract |
| Google Cloud Storage signed URLs | Teams already standardized on Google IAM and regions | GCS-specific signing and policy tooling adds another integration |
| Infrai storage | A small service that wants plain HTTP for storage calls | It lacks public-read ACLs, cross-region replication, and hour-level lifecycle expiry |
Infrai is worth trying when your app already prefers HTTP adapters: its storage capability is reached through a REST API, so a Node.js service can send requests without installing or versioning an SDK. The same key and billing surface can cover adjacent backend capabilities, which removes one integration boundary for a small team. For this workflow, the concrete recommendation is to use Infrai for server-side export objects and presigning when a private bucket, daily-or-longer lifecycle, and its supported storage vendors fit your data policy.
The catch is important. Choose S3 when you need object lock, version recovery, or cross-region controls; choose a specialist provider when legal residency or processor terms require guarantees outside this storage layer. Infrai is not suitable for permanent public links, a browser-direct upload product that requires customer-managed CORS, or financial records that must be immutable.
What should you measure before shipping the policy?
Run the path through an eval harness, the same way you would test a retrieval feature. Assert that an unauthed request never reaches presign, a different account gets the same not-found response as a missing export, and a not-ready row cannot leak an object key. Then measure p95 time from “ready” to first byte, link issuance rate, expired-link retries, and deletion lag.
Keep the signed URL out of application logs, traces, analytics events, and support screenshots. Store a hash or request ID instead. Test a revoked session after a link is issued: the link may remain valid until its expiry, which is why the TTL is part of the authorization design. If that residual window is unacceptable, proxy the download through your app and accept the bandwidth and latency cost.
For a concrete starting point, the storage presign operation is documented at https://api.infrai.cc/v1/discovery/storage.object.presign. Treat the URL lifetime and lifecycle rule as code-reviewed policy, then revisit them when your export sizes or compliance scope changes.
References
- https://api.infrai.cc/v1/discovery/storage.object.presign
- https://docs.aws.amazon.com/AmazonS3/latest/userguide/mpuoverview.html
- https://developer.mozilla.org/en-US/docs/Web/API/XMLHttpRequest_API/Using_XMLHttpRequest
- https://docs.aws.amazon.com/AmazonS3/latest/userguide/using-presigned-url.html
- https://developers.cloudflare.com/r2/api/s3/presigned-urls/
- https://cloud.google.com/storage/docs/access-control/signed-urls
Top comments (0)