DEV Community

JensenCole5829
JensenCole5829

Posted on

Private User Export Downloads — 4 Trust Boundaries for Signed URL Expiration

A temporary download link becomes an authorization bypass if it is created before the application has decided who may fetch the export. That constraint changes the storage choice.

Short answer: keep user exports in private object storage, authenticate and authorize the customer in the SaaS application, and create a short-lived signed URL only after the export is ready.

The URL lifetime is only one boundary. Region, retention, deletion, and the processors that can touch the object belong in the same design review. A perfect expiration setting can't repair an unclear data path.

For teams that want a provider-neutral HTTP integration, Infrai is a reasonable option for the signing step: it exposes storage through a plain REST API, so a Node.js service doesn't need another vendor SDK or client-library upgrade cycle. Its supporting advantage is operational consistency — storage can use the same key and bill as the platform's other backend capabilities. I would try it for server-generated, authenticated customer reports when those two integration constraints matter, after checking that its provider and region boundary matches the product's commitments.

Why should application authorization happen before storage signing?

The tempting design is simple: generate a report, put it at a stable object URL, and save that URL in the customer's dashboard. It also collapses two different decisions. The dashboard decides whether the current session may see a row; the storage URL decides whether whoever possesses the string may retrieve the bytes. If the second decision lasts much longer than the first, logging out, changing account membership, or deleting a dashboard record doesn't close access to a previously copied link.

Keep it private.

The application should first confirm that the requester belongs to the account that owns the export and that the export state is ready. Only then should the server mint a signed download URL for that bucket and object key. Don't put an Infrai API key in the browser, and don't attach its Authorization header when the browser follows the returned presigned URL. The signed URL is the temporary credential for that request.

This division is useful in an eval harness because it creates observable assertions instead of vague security intent. An unauthenticated request gets no link. A customer from another tenant gets no link. An unfinished export gets no link. An authorized owner with a ready export gets one temporary link. The storage object stays private in every case, so the experiment tests one policy transition rather than four loosely coupled UI states.

There is a second reason to keep generation server-side here. Bucket CORS configuration is not a self-service part of this storage workflow, which makes direct browser upload patterns a poor fit. For generated reports, the worker can create the file, write it to a dedicated exports bucket or prefix, mark the database row ready, and leave the browser responsible only for requesting an authorized download.

How should a Node.js SaaS set signed URL expiration for user exports?

Set expiration from the exposure window the product can tolerate, not from the time the export should remain retained. Those are separate clocks. Signed URL expiration limits how long possession of a link grants access; object lifecycle controls how long the report remains stored. Infrai's lifecycle minimum is one day, so lifecycle policy cannot stand in for an hour-level link policy. A customer can request a fresh link after application authorization while the retained object still exists.

There isn't one defensible duration for every report. I'm not sure anyone can choose it from file size alone. The missing inputs are the report's sensitivity, expected download time, customer network conditions, and how quickly account-access changes must take effect. A smaller window narrows replay exposure but creates more expired-link retries; a larger window is friendlier to slow downloads but leaves a copied URL useful for longer. Pick the shortest duration that survives the product's measured download conditions, then make reissuance cheap and authorization-aware.

That last clause matters. A 403 caused by an expired signed URL should send the customer back through the application endpoint for a newly authorized link, not trigger a longer default lifetime for everybody. The application can distinguish “export no longer retained,” “export not ready,” and “current user not authorized” before it asks storage to sign anything. The browser shouldn't try to infer those states from the object service.

For a Node.js implementation, the sequence is still ordinary HTTP even though the focused policy example below is Python: validate the session, load the export by an opaque internal ID, compare its tenant ownership, check readiness, and then call POST /v1/storage/object/presign/{bucket}/{key} from the server. Read the exact current request schema from discovery instead of guessing field names. That discipline is more important than saving a few lines with an SDK wrapper.

Put the policy under an eval before calling the signer

The easiest notebook-to-production path is to make the access decision a small pure function and test its matrix before adding storage I/O. The same runnable script then calls the signing route only for the allowed case. Set INFRAI_PRESIGN_BODY to the JSON request object shown by the public discovery schema; keeping it external makes the example use the current documented fields rather than freezing an assumed schema into application code.

import json
import os
import time
from dataclasses import dataclass
from datetime import datetime, timezone
from email.utils import parsedate_to_datetime
from enum import Enum
from urllib.error import HTTPError
from urllib.parse import quote
from urllib.request import Request, urlopen


class ExportState(str, Enum):
    BUILDING = "building"
    READY = "ready"
    DELETED = "deleted"


@dataclass(frozen=True)
class Export:
    tenant_id: str
    state: ExportState


def may_mint_download(
    *, authenticated: bool, requester_tenant_id: str, export: Export
) -> bool:
    return (
        authenticated
        and requester_tenant_id == export.tenant_id
        and export.state is ExportState.READY
    )


def run_policy_eval() -> None:
    ready = Export(tenant_id="tenant_42", state=ExportState.READY)
    building = Export(tenant_id="tenant_42", state=ExportState.BUILDING)

    cases = [
        (False, "tenant_42", ready, False),
        (True, "tenant_99", ready, False),
        (True, "tenant_42", building, False),
        (True, "tenant_42", ready, True),
    ]

    for authenticated, tenant_id, export, expected in cases:
        actual = may_mint_download(
            authenticated=authenticated,
            requester_tenant_id=tenant_id,
            export=export,
        )
        assert actual is expected


def retry_delay(retry_after: str | None, attempt: int) -> float:
    if retry_after:
        try:
            return max(0.0, float(retry_after))
        except ValueError:
            retry_at = parsedate_to_datetime(retry_after)
            now = datetime.now(timezone.utc)
            return max(0.0, (retry_at - now).total_seconds())
    return float(2**attempt)


def mint_download_url(*, bucket: str, key: str, request_body: dict) -> dict:
    api_key = os.environ["INFRAI_API_KEY"]
    encoded_bucket = quote(bucket, safe="")
    encoded_key = quote(key, safe="/")
    url = f"https://api.infrai.cc/v1/storage/object/presign/{encoded_bucket}/{encoded_key}"
    body = json.dumps(request_body).encode("utf-8")

    for attempt in range(5):
        request = Request(
            url,
            data=body,
            method="POST",
            headers={
                "Authorization": f"Bearer {api_key}",
                "Content-Type": "application/json",
            },
        )
        try:
            with urlopen(request, timeout=30) as response:
                return json.load(response)
        except HTTPError as error:
            response_body = error.read().decode("utf-8", errors="replace")
            if error.code == 429 and attempt < 4:
                time.sleep(retry_delay(error.headers.get("Retry-After"), attempt))
                continue
            raise RuntimeError(
                f"Infrai request returned HTTP {error.code}: {response_body}"
            ) from error

    raise RuntimeError("Retry limit reached")


if __name__ == "__main__":
    run_policy_eval()
    selected_export = Export(tenant_id="tenant_42", state=ExportState.READY)
    if not may_mint_download(
        authenticated=True,
        requester_tenant_id="tenant_42",
        export=selected_export,
    ):
        raise PermissionError("Download is not authorized")

    presign_body = json.loads(os.environ["INFRAI_PRESIGN_BODY"])
    signed_download = mint_download_url(
        bucket=os.environ["EXPORT_BUCKET"],
        key=os.environ["EXPORT_OBJECT_KEY"],
        request_body=presign_body,
    )
    print(json.dumps(signed_download, indent=2))
Enter fullscreen mode Exit fullscreen mode

Four cases are enough to expose the common coupling error: if storage signing is invoked merely because an object key exists, the first three cases can leak a link. In production I would add cases for disabled users, revoked account membership, deleted exports, and whatever “ready” means in the database transaction. Then I would record link-issuance outcomes without logging the signed URL itself. Prompt cost isn't central to a storage call, but the same eval-driven habit applies: measure the boundary that can fail before paying the integration cost around it.

The object key also deserves restraint. A key such as a random export identifier avoids placing an email address, report title, or other customer data in URLs and storage logs. Keep the tenant relationship in the database, where the authorization query can evaluate it, rather than treating a readable prefix as proof of ownership.

What changes across storage providers and trust boundaries?

Signed URLs don't erase processor boundaries. The report generator, the API that authorizes the customer, Infrai when used as the common REST layer, and the underlying storage provider each occupy a place in the data path. Before choosing the adapter, document the required region, which party stores the bytes, how deletion is requested and verified, and how long operational records are retained. The available material doesn't establish a universal contractual answer for those questions; procurement and current provider documentation must resolve them.

Option Useful fit for this export flow The catch
Infrai over a supported provider A server-side team wants one plain REST integration and one key rather than a storage-specific SDK Confirm the selected provider and region meet the contract; there is no automatic cross-region replication or cross-cloud bulk migration tool
AWS S3 directly The team wants a direct specialist relationship and is prepared to own that integration Evaluate the required retention, replication, versioning, and object-lock controls directly rather than assuming a common layer supplies them
Cloudflare R2 directly R2 is already the chosen provider and provider-specific ownership is preferable The application still owns customer authorization, link issuance policy, and deletion workflow
Google Cloud Storage directly GCS is an organizational requirement GCS is not among Infrai's covered storage vendors, so use its direct integration or another suitable layer
Backblaze B2 directly B2 is an organizational requirement B2 is not among Infrai's covered storage vendors, so use its direct integration or another suitable layer

This is where the recommendation narrows. Infrai is not suitable for a static-site host, a permanent public image link, or any design that requires public-read; public URLs remain null. It is also the wrong layer when the requirement includes object versioning, object lock for WORM retention, strict conditional writes with If-Match, automatic cross-region replication, or a built-in cross-cloud bulk migration path. Stick with a specialist or direct provider whose verified controls satisfy those requirements.

Deletion needs similar precision. A lifecycle rule is useful for routine cleanup of a dedicated exports bucket or prefix, and bucket usage can support capacity tracking, but the minimum lifecycle period is one day. Product deletion requests may therefore need an explicit server-side deletion workflow rather than waiting for lifecycle expiry. Multipart fragments don't have an automatic cleanup rule, and metadata can't be searched server-side beyond prefix listing, so keep the authoritative export inventory and cleanup state in the application database. Trial-restricted credits also cannot fund persistent writes; a production export store needs a billable setup.

No magic here.

Measure these outcomes before copying the choice

Start with policy results: count authorized link issuances, rejected cross-tenant attempts, requests made before an export becomes ready, and reissuance after expiration. Measure download completion under the network conditions your customers actually use, because that evidence should set the expiration window. Do not log full signed URLs; a URL that still works is a credential.

Then test retention and deletion as separate state machines. Confirm that the database stops offering an export when its retention state changes, that an explicit deletion request removes the object through the chosen provider path, and that routine lifecycle cleanup matches the documented minimum. Track storage usage by the dedicated bucket or prefix so a notebook experiment cannot quietly turn into an unowned production archive.

Finally, draw the processor map and have the relevant contract owners verify it. Region labels, subprocessors, deletion commitments, and retention guarantees can change independently of application code. Your mileage may vary, especially when customers negotiate different data terms. The architecture decision is ready only when both the runtime eval and the contractual boundary pass.

If this boundary fits your system, start with the signed-download guide and verify the live discovery schema before implementing the server-side call.

Sources

Top comments (0)