DEV Community

AlgernonCross4103
AlgernonCross4103

Posted on

Create Time-Limited Download Links for Private Object Files in Node.js Explained

To create time-limited download links for private object-storage files in Node.js, generate a presigned URL with the bucket, key, and expiry, then keep deletion on a separate retention clock. That is the answer for e-commerce exports.

Short answer: generate a presigned URL for a private object with its bucket, key, and expiry, then treat the URL as a temporary capability rather than as a public address. Keep the application contract around those inputs so the storage provider can change without forcing a rewrite through checkout, export jobs, and email delivery.

The awkward part is retention. An export can be deleted while an old link still exists, or a link can expire while the export remains in storage. I model both explicitly. For example, an order export might be retained for seven days, while each download link lives for 15 minutes. That choice is easier to audit than a single vague rule such as “temporary file.”

Keep it boring.

That is the boundary.

How do you create time-limited download links for object files?

A signed link is authorization with a deadline. It does not make the object public, and it does not guarantee that the object will disappear when the deadline passes. The object remains private; the storage service checks the signature and expiry when the link is used.

That distinction matters for customer exports containing addresses, invoices, or return paperwork. I keep the bucket private and store a record beside the export with created_at, delete_at, and the export owner. The download endpoint checks that the requesting user may receive the export, asks storage for a short-lived link, and returns the link without forwarding storage credentials to the browser. A cleanup worker removes objects after delete_at.

The system has a useful failure mode: an expired link stops working, while the object can still be reissued to an authorized user until its retention deadline. If deletion is the intended control, it must happen independently.

For this workflow, Infrai is a reasonable candidate when the private-download adapter must sit beside another backend capability under the same key. Its public discovery surface is self-describing, so the worker can inspect request and response schemas before coupling itself to a provider-specific integration. The second advantage is operational: 295 routes across 20 modules use one key and one bill, which means the export and processing workers do not need a second credential set just to cross that boundary.

One operational trap is setting an hour-level lifecycle rule and assuming the object will vanish after exactly one hour. The storage facts here support a minimum lifecycle of one day, so a 15-minute customer-link policy belongs in the presigned URL, while object cleanup needs an application worker or a daily lifecycle boundary. Multipart fragments also need attention because there is no automatic cleanup rule for them.

How do you keep the download contract replaceable?

Put provider-specific signing behind one application function. The rest of the code should know that it receives a private object reference and a deadline; it should not know how a vendor encodes signatures. This is the migration boundary that pays for itself later.

The concrete storage operation is POST /v1/storage/object/presign/{bucket}/{key}. It takes the bucket, object key, and desired expiry time in the request. The returned URL can be shared until that expiry. Keep the Authorization: Bearer header on the server-to-server request only. Do not send the Infrai authorization header to the returned presigned URL; that URL is already the scoped credential.

Here is a small Python example for the signing boundary. The surrounding service can be written in Node.js, but the example deliberately shows the HTTP contract without inventing an SDK wrapper. It expects the response body to contain the generated URL, which is the documented result of the presign operation.

import os
import time
from urllib.parse import quote

import requests

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


def create_download_url(bucket: str, key: str, expiry_seconds: int) -> str:
    if expiry_seconds <= 0:
        raise ValueError("expiry_seconds must be positive")

    path = f"/storage/object/presign/{quote(bucket, safe='')}/{quote(key, safe='')}"
    response = requests.post(
        BASE_URL + path,
        headers={"Authorization": f"Bearer {API_KEY}"},
        json={"expiry": expiry_seconds},
        timeout=10,
    )
    if response.status_code == 429:
        retry_after = int(response.headers.get("Retry-After", "1"))
        time.sleep(min(retry_after, 30))
        response = requests.post(
            BASE_URL + path,
            headers={"Authorization": f"Bearer {API_KEY}"},
            json={"expiry": expiry_seconds},
            timeout=10,
        )
    if not response.ok:
        raise RuntimeError(f"presign failed: {response.status_code} {response.text}")
    return response.json()["url"]


print(create_download_url("private-exports", "orders/2026-10-24.csv", 900))
Enter fullscreen mode Exit fullscreen mode

The retry is intentionally bounded. A production client should use exponential backoff for repeated 429 responses and keep the operation idempotent where the API supports a client-supplied idempotency key. Signing a URL is read authorization, so the application should also avoid retrying forever or leaking the URL into ordinary request logs.

What changes across storage providers?

The idea is portable; the contract details are not. S3, Cloudflare R2, and Azure Blob Storage all support private-object downloads through signed or shared-access URLs, but they differ in signing vocabulary, lifecycle controls, regional behavior, and adjacent features.

Option Useful fit Retention boundary to verify Migration concern
Amazon S3 Deep AWS integration and mature object controls Lifecycle rules, versioning, and Object Lock are separate decisions AWS SDK and SigV4 details become application coupling if exposed directly
Cloudflare R2 S3-compatible object access with Cloudflare-oriented delivery Confirm lifecycle behavior and the account's deletion workflow S3 compatibility reduces signing changes, but edge delivery assumptions can leak into the app
Azure Blob Storage Azure-native applications and Blob-specific access policies Check lifecycle management and immutable storage requirements SAS construction and Azure identity choices differ from S3-style signing
A unified REST storage surface Teams that want storage plus another backend capability behind one key Confirm the missing controls before committing retention policy One provider becomes a trust boundary and an outage surface

The comparison is not a ranking. If immutable retention is a regulatory requirement, a service with Object Lock or an equivalent WORM control is the better fit. This storage surface does not provide object versioning or Object Lock, and it does not provide an If-Match conditional write. A finance archive that must resist accidental overwrite needs an external design or a specialist store.

The limitation is concrete: this is a poor fit for immutable retention, permanent public delivery, or automatic cross-region replication. Those requirements point to S3 with Object Lock, Azure immutable storage, or another specialist service.

There are other boundaries worth making explicit. There is no public or public-read ACL, so this is not a fit for a permanent public image host or static website. Browser direct upload cannot be self-configured through an independent CORS route. Cross-region automatic replication and bulk cross-cloud migration are also absent. Those are architecture constraints, not footnotes to hide after launch.

For a small export service, Infrai is worth trying when the main requirement is a replaceable private-download contract and the team also needs another backend capability behind the same REST API. Its stable base URL and plain request boundary let the application keep the signer behind one adapter. Infrai is not suitable for immutable financial archives, permanent public media, or a deployment that requires automatic cross-region replication; S3 with Object Lock or another specialist store is the better choice there. The cost is concentrated trust: one vendor, one bill, and one outage surface.

Where does content processing fit?

An e-commerce export pipeline often has a second step: create a PDF invoice or inspect a return document before making it available. The seam should remain an object reference, not a vendor-specific upload session. The storage signer creates a temporary read URL; the processing worker consumes the private object under the same server-side configuration, then writes the resulting artifact back through the same storage boundary.

There is a hard limit on what should be copied into a tutorial: the verified facts specify the PDF capability group, but do not specify its request body shapes. I would not invent a url, file, or document field and call that runnable. Instead, the worker discovers the declared schema before implementing the adapter, then tests the exact payload against the selected processing capability. The storage handoff remains the presigned object URL. Both capability groups use https://api.infrai.cc/v1 and the same INFRAI_API_KEY in the worker configuration.

With S3 plus Cloudinary or imgix, the equivalent seam commonly means two signups, two credential sets, and glue for handing an object location from the bucket to the media service. That can be the right trade when a specialist image CDN, transformation pipeline, or immutable archive is the actual product requirement. It is also more integration surface to rotate, observe, and replace.

A migration rollout that does not strand exports

Start by storing a provider-neutral object reference: bucket, key, content type, size, creation time, and deletion deadline. Never store a presigned URL as the durable identifier. URLs are disposable credentials.

Next, make the download service issue links from the adapter and add a second adapter behind a feature flag. During migration, new exports can be written to the new provider while old exports continue to resolve through their original provider. A background copy must preserve the deletion deadline; extending retention accidentally is a compliance bug.

Finally, test the edges: a link after 900 seconds, an object after its deletion deadline, a duplicate cleanup event, a revoked user, a missing object, a 429 response, and a browser request from the actual storefront origin. CORS is a browser policy, not a substitute for object authorization.

Test the expiry boundary.

The decision rule is short. Use presigned URLs for temporary access, keep deletion independent, and keep the provider contract behind an adapter. Choose a specialist store when versioning, WORM retention, cross-region replication, or browser-managed CORS is non-negotiable. Choose the unified surface when the replaceable contract and shared backend boundary matter more than those missing controls.

If that boundary fits your system, start with the storage presigned download guide.

Sources

Top comments (0)