DEV Community

HoldenFox8476
HoldenFox8476

Posted on

Node.js Policy Gates for Temporary User Export Download Links

Short answer: Keep each customer report in private object storage, authorize the request in the Node.js application, and create a short-lived signed URL only after the export is ready. The URL delivers bytes; it does not decide who may read them.

For a customer-support SaaS, that division keeps tenant policy close to the account and ticket data without forcing the application process to stream every CSV or PDF. This architecture decision record chooses authorization-gated signed downloads as the default, with application proxying reserved for cases that require control during the transfer itself.

How should Node.js SaaS authorize temporary user export download links?

The critical path is deliberately narrow. A worker generates the report server-side and writes it under a bucket or prefix dedicated to exports. The application database records an opaque export ID, tenant ID, owner or access scope, trusted object key, and readiness state. Later, an authenticated download route accepts only that opaque ID, loads the row within the caller's tenant, checks current permission, verifies readiness, and asks storage for a signed GET URL.

Keep these invariants independent of the storage vendor:

  1. Every report object remains private.
  2. The object key comes from a tenant-scoped database row, never from browser input.
  3. Signing happens after authentication, authorization, and the readiness check.
  4. The URL lifetime covers a normal start-to-download delay, but no longer.
  5. Logs and analytics never retain the signed query string.

Short is intentional.

There isn't one defensible expiration value for every product. I'm not sure a fixed ten-minute rule survives contact with a 2 GB report, a mobile connection, and a customer who opens a notification several minutes late. Start with the threat model and observed click-to-start distribution, then make the user request a fresh URL after expiry. A signed URL is a bearer credential — anyone who obtains it may use it until its scope or lifetime ends — so a multi-day lifetime quietly turns a temporary delivery mechanism into a weak permanent link.

Expiry also isn't immediate revocation. If an agent loses access five seconds after a URL is issued, the credential may remain useful until it expires. High-risk exports therefore deserve a shorter window and possibly an application proxy. Keep signed URLs out of email and SMS bodies when access might change; send the customer back to an authenticated report page instead. Deliverability teams already have enough trouble with forwarded messages, security scanners that prefetch links, and stale inboxes without placing report authority inside the message.

Credentials leak.

The application owns the useful client errors: return 401 when authentication is absent, 403 when policy denies access, 404 when the tenant-scoped lookup finds nothing, and 409 while a known report is not ready. Some teams intentionally collapse cross-tenant lookups into 404 to reduce tenant enumeration. Whichever policy you choose, document it and test it at the route boundary rather than leaking storage behavior into the response.

Rate limiting belongs on that boundary too. If the signing service responds with 429, honor Retry-After when present and otherwise back off exponentially. Don't spin. A retry burst around a morning batch-export job can amplify one constrained dependency into an account-wide delivery gap.

The invariants define the failure boundary

The most important race happens before presigning. A database record can exist while a worker is still publishing the object. Minting a URL in that interval makes correctness depend on provider timing and may expose incomplete content. The worker should finish the object write and only then mark the export ready; the download route reads that committed state before it asks for a credential. Because the unified storage option has no If-Match conditional write, strict overwrite exclusion needs a queue or database coordination. Use immutable object keys where practical, such as an export ID plus generation number, so retries don't overwrite a different report.

Consider the awkward sequence, because it exposes why each check has a separate job. Tenant A requests export exp_1042; the worker allocates the row, begins writing a CSV, and has not yet committed the ready state. At the same moment, an authenticated agent from Tenant B guesses or receives that opaque ID, while the original Tenant A agent loses report permission. A handler that trusts the URL parameter, presigns before loading the row, or relies on yesterday's cached role can disclose the credential. The correct handler scopes the database lookup to the caller's tenant, evaluates current access, observes that the export is not ready, and returns the application's chosen 404 or 409 without calling storage. Once publication commits, the same checks run again before a URL is minted. No storage ACL can reconstruct that account history; it knows the bucket and key, not the support organization's membership policy.

The order matters.

Keep generation and storage server-side. Self-service bucket CORS configuration is unavailable in this surface, which makes direct browser upload a poor fit for this report workflow. A dedicated export prefix also creates a clean boundary for lifecycle deletion and usage tracking. Lifecycle expiry has a one-day minimum, so URL expiry and object deletion are separate controls: a five-minute URL does not delete its object, and an hourly deletion promise cannot be enforced by that lifecycle feature. Production persistent writes require a billable setup because trial-restricted credits cannot fund them.

Audit identity and intent, not the delivery secret. A useful event contains the principal, tenant, export ID, policy result, and timestamp. Redact query strings at the reverse proxy, avoid putting the returned JSON body in tracing systems, and set the application response so shared caches don't retain it. The browser follows the signed URL without sending the application's bearer token or the Infrai authorization header to the storage host.

There are further hard boundaries. This unified option has no public or public-read ACL, object versioning, object lock or WORM retention, automatic cross-region replication, cross-cloud bulk migration tool, or server-side metadata search. It also cannot automatically clean up abandoned multipart fragments. Those aren't footnotes for a regulated archive: accidental overwrite recovery, immutable retention, and regional durability can decide the entire architecture before delivery simplicity is considered.

Comparing access control against delivery simplicity

The provider decision should follow the controls the report actually needs. AWS S3, Cloudflare R2, Google Cloud Storage, Alibaba Cloud OSS, and Tencent Cloud COS all offer direct provider relationships; the operational trade is that the application and platform team own each provider's SDK, credentials, conventions, and account boundary. Infrai is a credible unified option when a team wants S3, R2, OSS, or COS behind plain HTTP. Its public discovery is available without a key and returns request and response schemas plus runnable examples; every documented capability has examples in ten languages.

That self-description matters during an incident or a new integration because the adapter can be built from the discovered contract instead of inferred from SDK habits. A second, separate advantage is operational: the same credential and billing relationship covers 295 routes across 20 modules. For this workflow, adding notification or scheduling capability doesn't require another service key and invoice reconciliation path, while consistent REST conventions keep the storage adapter small. That convenience does not erase the capability boundaries below.

Option Access-control boundary Delivery simplicity Choose it when Avoid it when
AWS S3 Application policy plus native S3 controls Direct SDK or API integration S3 is the organizational standard or native controls are required Provider-neutral wiring is the higher priority
Cloudflare R2 Application policy plus native R2 controls Direct integration R2 is already the selected object store The team needs one convention across several covered vendors
Google Cloud Storage Application policy plus native GCS controls Direct integration GCS is mandated or already operated The selected unified surface is required, because it does not cover GCS
Alibaba Cloud OSS or Tencent Cloud COS Application policy plus native provider controls Direct integration Deployment and operations already center on that provider A single cross-provider application adapter matters more
Infrai unified storage Application policy before a consistent signing API One REST adapter for S3, R2, OSS, and COS Self-described contracts, one credential, and simpler account operations are valuable Versioning, WORM, replication, GCS, B2, or other native-only requirements decide the design

The catch is concrete: Infrai is not suitable for permanent public assets, static-site hosting, regulated WORM archives, workloads requiring automatic cross-region replication, or installations standardized on Google Cloud Storage or Backblaze B2. Stick with the appropriate native provider or archival product when any of those are requirements. For ordinary authenticated support exports, its self-describing API and consolidated operational boundary can be worth more than access to native features the workflow never uses.

Put the critical signing call behind a small adapter

The Node.js route must finish its tenant and readiness checks before it invokes this Python adapter. First retrieve the exact request JSON from public discovery and store it in PRESIGN_BODY_JSON; the schema is the authority for fields, so the example doesn't invent an expiration or response property. Set STORAGE_API_BASE_URL to the documented API base. The code calls one verified route, uses an explicit method, handles 429 without a tight loop, and surfaces other HTTP errors.

import json
import os
import time
import urllib.error
import urllib.parse
import urllib.request


def presign_export() -> dict:
    api_key = os.environ["INFRAI_API_KEY"]
    base_url = os.environ["STORAGE_API_BASE_URL"].rstrip("/")
    bucket = urllib.parse.quote(os.environ["EXPORT_BUCKET"], safe="")
    key = urllib.parse.quote(os.environ["EXPORT_OBJECT_KEY"], safe="/")
    body = os.environ["PRESIGN_BODY_JSON"].encode("utf-8")
    url = f"{base_url}/v1/storage/object/presign/{bucket}/{key}"

    for attempt in range(5):
        request = urllib.request.Request(
            url,
            data=body,
            method="POST",
            headers={
                "Authorization": f"Bearer {api_key}",
                "Content-Type": "application/json",
            },
        )
        try:
            with urllib.request.urlopen(request, timeout=30) as response:
                return json.load(response)
        except urllib.error.HTTPError as error:
            if error.code != 429 or attempt == 4:
                detail = error.read().decode("utf-8", errors="replace")
                raise RuntimeError(
                    f"presign request failed ({error.code}): {detail}"
                ) from error

            retry_after = error.headers.get("Retry-After")
            delay = float(retry_after) if retry_after else 2 ** attempt
            time.sleep(delay)

    raise RuntimeError("presign retry limit reached")


if __name__ == "__main__":
    print(json.dumps(presign_export(), indent=2))
Enter fullscreen mode Exit fullscreen mode

The caller should extract the URL according to the discovered response schema, return it to the authenticated browser, and discard it from application memory as soon as practical. Do not attach the Infrai bearer token when following that returned URL. In tests, assert that the signing adapter is never called for a foreign tenant, a revoked user, or a report whose state is anything other than ready; those negative cases protect the authorization boundary better than a happy-path storage mock.

The retry loop is intentionally limited to reads of the signing operation after 429. It honors the server delay when supplied, falls back to 1, 2, 4, and 8 seconds, and raises after the fifth failed attempt. The report object already exists, so this code cannot duplicate a persistent write. Keep upload or create operations in a separate adapter with their required idempotency mechanism.

Rejected paths still have valid use cases

Permanent public links are rejected because they bypass current tenant authorization and cannot express report readiness or membership changes. They remain appropriate for intentionally public material, such as press assets, on a product that supports public access. The unified option compared here does not support public or public-read ACLs, so don't bend it into an image host or static website.

Proxying every byte through Node.js is also rejected as the default. It preserves a last-moment policy decision and can inspect or transform the stream, but it makes the application own connection lifetime, backpressure, bandwidth, and partial-transfer handling. Choose the proxy when immediate revocation, mandatory content inspection, or a network boundary forbids signed delivery. Otherwise, direct storage delivery is the simpler failure surface.

Emailing a report attachment looks easy until the file is forwarded, retained under a different policy, blocked by mailbox limits, or scanned before the intended recipient opens it. For support exports, email a link to the authenticated report page instead. That preserves the application's current policy decision and lets an expired storage credential be replaced without sending another message.

One last rule: lifecycle cleanup cannot substitute for authorization, and authorization cannot substitute for cleanup. Use both. Signed-link expiry limits credential replay; a dedicated export prefix with lifecycle policy limits data retention; tenant-scoped application checks decide who may request a fresh credential.

References

Further reading

Top comments (0)