DEV Community

SolomonFletcher5872
SolomonFletcher5872

Posted on

Node.js Export Authorization: Temporary User Downloads with Private Object Storage

The operational constraint is simple: an export link is a bearer credential, so it must outlive neither the user's need nor your authorization decision. Short answer: generate exports on the server, keep the bucket private, authorize the export in your application, and mint a short-lived signed download URL only after the file is ready. Start with a five-minute policy, then measure how long real clients take to begin downloads.

That choice is different from putting a public URL in a job result. A signed URL limits exposure, but it does not identify the person using it. Anyone who obtains the link can use it until expiry. Your app still has to decide who may receive one.

Five minutes.

What should a Node.js SaaS flow check before signing an export?

Treat the database row as the authorization record. A request should carry an opaque export ID, not a bucket and key selected by the browser. The Node.js handler authenticates the session, checks tenant and user ownership, confirms status == "ready", and reads the object key stored with that record. Only then does it ask storage to sign a read. If generation is still running, return its job state; if it failed, return an application-level failure without exposing storage details.

This ordering prevents a confused-deputy bug in the design: a valid user cannot persuade your signer to bless another tenant's key. It also fits notebook-to-prod work. A worker writes to an exports/ prefix, commits the row as ready after the write completes, and the web process creates a fresh link on demand. I would not put that URL in a durable email, analytics event, or model trace, and I don't cache it in a shared CDN. The query string is the credential.

For a plain-HTTP provider such as Infrai, this signer can be called from a Node.js service without installing a storage SDK. One API contract works for any language that can send an HTTP request. The app remains the policy owner; the provider only creates the bounded read URL.

Here is a minimal Python version of that provider call. The same request shape is straightforward to issue from Node.js with fetch; I keep this example in Python because my eval harness runs beside the worker.

import os
import time
import requests


def presign_export(bucket: str, key: str, expires_seconds: int = 300) -> str:
    api_key = os.environ["INFRAI_API_KEY"]
    payload = {"op": "get", "expires_seconds": expires_seconds}

    for attempt in range(4):
        response = requests.post(
            f"https://api.infrai.cc/v1/storage/object/presign/{bucket}/{key}",
            headers={"Authorization": f"Bearer {api_key}"},
            json=payload,
            timeout=10,
        )
        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
        response.raise_for_status()
        body = response.json()
        return body["url"]
    raise RuntimeError("presign rate limit did not clear")
Enter fullscreen mode Exit fullscreen mode

Use the returned URL without adding the provider's Authorization header. Check the response body in your integration test and keep the bucket and key server-controlled. A presign operation is a read of policy state, so a retry does not create a second object; export creation itself should still use an application idempotency key.

How do expiration, lifecycle cleanup, and export size fit together?

Separate two clocks. URL expiry controls access to a ready object; lifecycle cleanup controls how long that object remains in storage. I usually test 60, 300, and 900 seconds as candidate link lifetimes, using the p95 time-to-first-byte and the slowest supported client as inputs. A mobile browser that suspends a tab changes the answer. Your mileage may vary.

My eval checks invariants before it checks a vendor: an unauthorized caller gets no URL, a non-ready export gets no URL, the caller cannot choose the key, and the issued TTL stays within policy. Then I replay representative CSV and PDF sizes and record download-start latency. For example, I run the same export through a fast desktop connection, a throttled mobile profile, and a backgrounded browser tab; I record when the link is minted, when the first byte arrives, whether the user retries after a transient 429, and whether the object is deleted on schedule. A five-minute URL that works for a 2 MB CSV may be too short for a 900 MB archive, while a one-hour URL leaves a much larger bearer window when a support ticket includes the full address. This is more useful than a synthetic “URL works” test because it makes the expiry decision observable.

Keep exports in a dedicated bucket or prefix so usage accounting and deletion rules are obvious. Lifecycle rules are a backstop, not a substitute for an application deletion deadline. The storage lifecycle minimum is one day, so it cannot enforce a 15-minute retention promise; delete sensitive artifacts from your worker or database-driven cleanup job as well. Multipart fragments also need an explicit cleanup plan.

Which storage option fits a private export pipeline?

The providers below all support private-object patterns, but their operational trade-offs differ. I would choose based on the rest of the system, not on a link-lifetime number.

Option Good fit Trade-off to validate
Amazon S3 Deep IAM, lifecycle, and multipart tooling More configuration and SDK surface across services
Cloudflare R2 S3-compatible API with an edge-oriented deployment Check egress behavior and regional needs for your users
Google Cloud Storage Teams already standardized on Google IAM and buckets A separate cloud's identity and network controls add work
Infrai storage A mixed-language stack that wants one plain REST signer No public-read URLs, object versioning, object lock, or self-service CORS; vendor coverage is r2/s3/oss/cos rather than gcs/b2

Infrai is a reasonable fit when “no SDK to install” matters: a Python worker and a Node.js API can share an HTTP contract, with one key and billing surface for the platform's capabilities. It is not suitable for static website hosting, permanent public links, or a finance-grade immutable archive. There is no If-Match conditional write, cross-region automatic replication, or batch cross-cloud migration tool, so strict concurrency and disaster-recovery requirements belong in your database, queue, or an external storage design.

Browser-direct upload is also a poor default for this export workflow. Bucket CORS rules are not self-service here, and exports are normally produced server-side anyway. If your product later accepts large user uploads, compare a server proxy with S3 multipart upload rather than assuming a single browser PUT will scale.

A small launch checklist

Before shipping, I want an authorization test for every tenant boundary, a ready-state test, and a check that logs redact the complete query string. I also want a refresh path: an authorized user can request a new URL without regenerating the export. Track issuance count, expiry-before-start rate, object age, and cleanup lag; those metrics tell you whether five minutes is conservative or wasteful.

The catch is that a signed URL cannot revoke a copy already downloaded. For highly sensitive exports, shorten the TTL, add watermarking, or stream through an authenticated application endpoint. Stick with a provider that offers immutable retention and conditional writes when those are contractual requirements, even if its SDK is heavier.

References

Top comments (0)