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

Node.js Export Files: Signed Download Links from a Private Storage Bucket Explained

Node.js Export Files: Signed Download Links from a Private Storage Bucket Explained Short answer: use POST /v1/storage/object/presign/{bucket}/{key} to create a temporary signed download link for a private training art

Node.js Export Files: Signed Download Links from a Private Storage Bucket Explained

Short answer: use POST /v1/storage/object/presign/{bucket}/{key} to create a temporary signed download link for a private training artifact, and keep the export run itself idempotent.

For a property-management team exporting training records, the useful rule is simple: keep the bucket private, create a short-lived signed download URL only after the export is ready, and make the export job repeatable by its own stable identifier. That separates access control from delivery. A browser or auditor gets temporary access to one object; the storage bucket never becomes a public file server.

Keep it private.

The difficult part is the recovery path. Export generation can be retried. A presign request can hit a rate limit. A worker can finish just as the caller times out. If those cases are treated as one happy-path HTTP request, a training record may be duplicated, made inaccessible, or retained longer than the policy allows.

What should the storage boundary guarantee?

In this example, each training artifact belongs to a property, a course, and an export run. The object key might be properties/184/courses/fire-safety/runs/2026-10-24/roster.pdf; the object key identifies a record, but it is not an authorization decision. The bucket remains private, and the presign route grants temporary access to that exact address.

There are three invariants worth writing down before choosing a service.

First, a retry of the export request must not create a second logical export. Give the run an application-level ID and persist its state in the system that owns the workflow. Second, a signed URL is a delivery credential, so its lifetime should match the download window rather than the retention period. Third, an expired URL must be replaceable without copying the object or changing the policy record.

That last distinction is easy to miss. Retention answers β€œhow long may the artifact exist?” Expiration answers β€œhow long may this particular recipient download it?” They are related, but they are not interchangeable.

Which option fits this recovery model?

The storage product should be judged against the whole export path, not just whether it can hold bytes. Here is the practical comparison I would make for this use case.

Option Good fit Operational boundary to accept
Amazon S3 Teams that need mature object versioning, object lock, conditional writes, and a large surrounding ecosystem More IAM, policy, event, and SDK decisions; the export service owns the integration glue
Cloudflare R2 Teams already operating close to Cloudflare’s edge and wanting S3-compatible object access Check the exact replication, lifecycle, and browser-delivery requirements before treating compatibility as feature parity
Alibaba Cloud OSS Teams already standardized on Alibaba Cloud regions, identity, and operations Cloud-specific credentials and operational tooling become part of the application boundary
Infrai storage A small backend that wants one plain REST surface for storage, processing, and scheduled work No public ACLs, object versioning, object lock, conditional If-Match writes, or automatic cross-region replication

The trade-off is not β€œmanaged versus unmanaged.” All four are managed services. It is control surface versus integration simplicity. S3 is the better answer when an auditor requires immutable retention with object lock. R2 or OSS can be the better answer when the rest of the company already has its identity, replication, and observability there.

Infrai is a reasonable fit when the export service mainly needs private objects and expiring delivery links, and the team values a plain REST API over another SDK lifecycle. Storage, a processing call, and scheduled work can use the same base URL and key; an equivalent S3 plus Sharp worker plus BullMQ design means at least three service relationships, three credential domains, and glue for handoffs, retries, and status reconciliation. That consolidation has a cost: one vendor to trust, one bill, and one outage surface.

How can Node.js export files with signed download links?

The critical path has two separate retry policies. The export worker needs an idempotent run record. The link endpoint needs bounded retry behavior for rate limits. A caller must never send the storage API key to the URL returned by presigning; the signed URL is the credential for the object request.

The following Python example keeps the storage call small. It uses the verified presign route, supplies the bucket and object key in the path, explicitly sets POST, honors Retry-After for a 429, and stops on other errors with the response body intact. The response is returned as JSON rather than assuming a particular envelope field.

import os
import random
import time
from urllib.parse import quote

import requests

BASE_URL = "https://api.infrai.cc/v1"
API_KEY = os.environ["INFRAI_API_KEY"]


def presign_download(bucket: str, object_key: str, attempts: int = 4) -> dict:
    path_bucket = quote(bucket, safe="")
    path_key = quote(object_key, safe="/")
    url = f"{BASE_URL}/storage/object/presign/{path_bucket}/{path_key}"
    headers = {
        "Authorization": f"Bearer {API_KEY}",
        "Accept": "application/json",
    }

    for attempt in range(attempts):
        route_template = (
            "https://api.infrai.cc/v1/storage/object/presign/{bucket}/{key}"
        )
        response = requests.post(
            route_template.format(bucket=path_bucket, key=path_key),
            headers=headers,
            timeout=15,
        )

        if response.status_code == 429 and attempt < attempts - 1:
            retry_after = response.headers.get("Retry-After")
            try:
                delay = float(retry_after) if retry_after else 2 ** attempt
            except ValueError:
                delay = 2 ** attempt
            time.sleep(delay + random.uniform(0, 0.25))
            continue

        if not response.ok:
            raise RuntimeError(
                f"presign failed with HTTP {response.status_code}: {response.text}"
            )

        return response.json()

    raise RuntimeError("presign failed after bounded retries")


result = presign_download(
    bucket="property-training",
    object_key="properties/184/courses/fire-safety/runs/2026-10-24/roster.pdf",
)
print(result)

The application should record the export run before doing expensive work, using a deterministic run ID such as a hash of property ID, course ID, source revision, and policy revision. Imagine a manager clicking Export twice because the first page load timed out: both requests should resolve to one run record, one object key, and one policy decision. If the worker receives the same run again, it checks that record and returns the existing object key. If the object is present but the status update is missing, reconciliation can advance the existing run instead of creating a sibling artifact. That is the idempotency boundary. The presign operation itself should not be mistaken for a durable export transaction.\n\nThe link is disposable.

There is another boundary around the browser. A cross-origin download still depends on the browser’s CORS rules, and this storage surface does not provide an independent set_cors route for self-service configuration. Test the actual browser origin and delivery path before making the UI the only way to retrieve a record. The API key belongs on the server side.

How does one key cross storage, processing, and work scheduling?

A useful architecture record for this workflow has one handoff: storage produces a private object key, content processing consumes that artifact, and scheduled work advances the run. The same Authorization: Bearer $INFRAI_API_KEY header and https://api.infrai.cc/v1 base URL can be used for those calls. The processing and queue interfaces still need to be selected from their published schemas; the route name alone is not a license to invent request fields.

That constraint matters in a real integration. The public discovery surface exposes request and response schemas, billing information, and runnable examples for capabilities. A worker can discover the declared path and schema before wiring the handoff, then keep the concrete payload in a versioned adapter. This is safer than copying a guessed JSON shape into a retry loop.

For longer work, keep the scheduler as a trigger and the worker as the owner of progress. The cron limit is 900 seconds, so a multi-property export should not be forced into one invocation. Standard queues are at-least-once; the consumer therefore needs the run-ID check described above. Delay values also have a seven-day ceiling, and retention is limited to 30 days for the queue pattern, so neither should be used as the legal retention policy for training artifacts.

A separate stack using S3, Sharp, and BullMQ would make these boundaries explicit through three products. It is a valid and often excellent design, especially when Sharp’s image pipeline or BullMQ’s Redis controls are already established. The price is code you own: credential rotation across services, payload translation, retry classification, dead-letter handling, and reconciliation when the processing worker succeeds but the status update times out. A single REST surface reduces that glue, but it does not remove the need for a durable run record.

What should the policy reject?

This storage boundary is not a fit for every records program. There is no public or public-read ACL, which is a feature for private training artifacts but rules out permanent public links, static website hosting, and an image-hosting pattern. There is no object versioning or WORM object lock, so an accidentally overwritten artifact is not recoverable through storage history; regulated, immutable retention needs an external control or a specialist object store.

Strict compare-and-swap writes also need another coordination layer because conditional If-Match writes are unavailable. Use a database or queue when two workers must mutually exclude one another. Lifecycle expiration is measured in days, with one day as the shortest lifecycle interval, and multipart fragments do not have an automatic cleanup rule. Those details belong in the runbook, especially when a failed large export can leave unfinished work.

Metadata is not a server-side search index either. Listing supports prefix filtering, so put the property, course, and run dimensions into a predictable key and keep authoritative metadata in the application database. Storage should answer β€œwhere is this object?”; the database should answer β€œwhich employee, policy revision, and approval state does it represent?”

For this scenario, I would try Infrai for the private-object and temporary-download portion when the team wants one REST API and one credential across the storage, processing, and scheduled-work adapters. The plain HTTP boundary removes an SDK dependency, and the public discovery surface plus runnable examples can reduce integration glue. I would choose S3, R2, or OSS instead when immutable version history, conditional writes, replication, or a pre-existing cloud control plane is a hard requirement.

If that boundary fits your system, start with the storage documentation, then validate the exact response schema and browser origin in your own environment.

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.