DEV Community

dawn li
dawn li

Posted on

A Node.js Gate Before Private Object Storage File Delivery

TL;DR

Keep export objects private, authenticate the person requesting the download, and redirect that request to a newly minted, short-lived presigned URL. A Node.js application should store an export record rather than a durable object URL; that record is where tenant authorization, expiry, and audit decisions belong.

The least complex design is an authenticated application endpoint followed by a redirect to object storage. It removes the application server from the data path without turning a forwarded link into a long-lived grant. The signature is useful, but it is only one boundary in a file-export system.

Start with the delivery constraint

A generated report has two distinct identities that are often collapsed into one: it is a blob of bytes, and it is a business artifact that belongs to a tenant, user, retention policy, and job. Object storage is well suited to the first identity. It is a poor place to make the second identity disappear behind a copied URL.

For a private export, the object path should be an implementation detail. The application owns an export identifier and records its owner, storage key, expected state, content type, suggested filename, and deletion time. A browser asks the application for that identifier. The application authenticates the request, checks that the caller may read the export, then issues a redirect to a URL authorized for one object and a short period. The object store handles the transfer directly.

That division matters. A presigned URL is a bearer credential: possession of the string is sufficient until its expiry. It can end up in browser history, logs, copied messages, and link scanners. Keeping it out of a durable email template means an administrator can later block the application route, remove the export record, or change authorization rules before another URL is issued. It does not revoke a URL that has already been handed out, so the lifetime still needs to be deliberate.

Small is safer.

There is a second constraint: an export job must not publish a download before its object is ready. Treat queued, writing, ready, expired, and deleted as application states, not as guesses inferred from a URL. Before moving an export to ready, validate the write using the storage interface appropriate to the service: confirm object existence, expected length, checksum or version where the system exposes one, and the encryption or retention properties required by policy. Then make the redirect endpoint available. This avoids the familiar race where a worker reports completion while a reader receives a link to an artifact that is absent, partial, or no longer authorized.

How should Node.js issue a presigned download URL for a private object-storage file?

The endpoint should do authorization before signing and should sign a read request for the exact object key recorded against the export. Do not accept a bucket or object key from the browser, and do not use a client-provided filename as an authorization input. Those values belong to the server-side export record.

The following Python-shaped pseudocode shows the boundary without binding the design to one storage SDK. The same sequence is what a Node.js route needs: load the export, verify the session and tenant, reject a non-ready record, create a short-lived signed GET request, and return a redirect.

def start_download(request, export_id, exports, signer):
    export = exports.get(export_id)
    if export is None or export.state != "ready":
        return response(status=404)

    if not request.user.can_read_tenant(export.tenant_id):
        return response(status=403)

    signed_url = signer.sign_get(
        object_key=export.object_key,
        expires_in_seconds=120,
        response_content_disposition=(
            f'attachment; filename="{export.download_name}"'
        ),
        response_content_type=export.content_type,
    )

    exports.record_download_attempt(export.id, request.user.id)
    return redirect(signed_url, status=302)
Enter fullscreen mode Exit fullscreen mode

The requested response headers are part of the signed request, so the recipient cannot change the desired attachment name by editing a query parameter. Content-Disposition: attachment asks the browser to download rather than render the response; use a filename that your application has already normalized for the platform and content type. The MDN documentation also calls out a practical detail: when both filename and filename* appear, modern browsers prefer filename*, which is relevant if exports have non-ASCII filenames.

Choose the URL lifetime from the user path, not from a generic security number. A human clicking through an authenticated page may need a minute or two. A link intended for a slow, resumable multi-gigabyte transfer may need a longer window, tested with the store's range-request behavior. Signature Version 4 presigned URLs can be created for up to seven days, but URLs signed with temporary credentials expire when those credentials expire, even when the requested URL lifetime is longer. Those are protocol and credential limits, not reasons to make every export link last a week.

CORS is frequently blamed for a problem that is actually a delivery-shape choice. A normal top-level navigation or redirect to the signed URL does not need a browser script to read a cross-origin response. If the web client uses fetch() to inspect the object or its headers, CORS applies and the storage response must allow the requesting origin, method, and any headers used by the request. Test that path in a browser; a command-line download is not a CORS test.

Decide where each control actually lives

The comparison is clearer after the constraints are explicit. Signing authorizes an object-store request. The application route authorizes a user. A delivery system often needs both.

Delivery pattern Authorization point What can be stopped later Operational cost Main limitation
URL placed directly in email Signing time Future issuance only Low Forwarding exposes the bearer URL until expiry
Authenticated route, then short redirect Request time and signing time The route can stop issuing new URLs One application hop Link scanners may request the route before the person does
Application streams the file Every byte request The application can deny the stream Connection capacity and egress through the service Large or slow downloads consume application resources
Signed policy at an edge layer Edge policy evaluation Depends on policy invalidation More delivery configuration Cache and authorization rules need careful review

For tenant reports, the authenticated-route pattern usually has the cleanest audit story: log an event at the point where a known principal requested a known export, then let storage transfer the bytes. The catch is that it is not suitable when recipients cannot authenticate to the application, such as an intentional external handoff. In that case, a narrowly scoped bearer link may be the required product decision, with a lifetime and sharing warning matched to the sensitivity of the file. A long-lived direct URL is still a poor substitute for access control.

Avoid treating a single GET as a single human action. Mail protection systems can prefetch links, browsers can retry, and download managers can make range requests. The redirect endpoint should therefore be safe to repeat. Record attempts separately from successful business acknowledgment, and do not burn a one-time application token merely because an automated scanner followed it. If a policy truly requires single-use delivery, define what counts as use and test it against those agents before promising the behavior.

How do you roll out a private export path and retain observability?

Introduce the export record and authenticated route before changing any outward-facing message. New notifications can link to the application route while existing links live until their own expiry. This lets a team observe the redirect path without invalidating old inboxes or coupling a storage migration to a communication migration. In a staged release, first create records for completed exports while retaining the current delivery path, then enable the route for an internal account, then send the new route in a small cohort of ordinary notifications. Check that the authorization decision names the expected tenant, that the signed object key is the one on the export record, and that the attachment name is right after the redirect, because a correct 302 only proves that the application made a choice, not that the recipient obtained the intended bytes. Follow the expiry boundary too: request the route before the record expires, open the redirect only after the signed URL expires, and confirm that the application issues a new URL only while the export record is still eligible. Those checks separate a storage-signing limit from an application-retention decision, which prevents support work from becoming a debate about an opaque query string.

Instrument the state transitions and the boundary crossings: job accepted, object verified, record marked ready, route requested, redirect issued, and object-store response where logs are available. Keep the signed query string out of ordinary request logs because it is a credential. Alert on exports stuck between writing and ready, and investigate a wide gap between issued redirects and completed downloads before treating it as a storage failure; delivery, client behavior, and corporate scanners can produce the same first symptom.

Run a small test matrix before release. Cover a permitted user, a user from another tenant, an expired record, a deleted object, a file with a non-ASCII attachment name, a browser navigation, a scripted fetch() path with CORS enabled, and a resumed download. Your mileage may vary with identity providers and client networks, but those cases make the assumptions visible while they are still cheap to change.

References

Top comments (0)