DEV Community

PhilemonShaw8453
PhilemonShaw8453

Posted on

Implement Node.js File Export: Temporary Download Links with Presigned Access Control

Short answer: implement the export as an authenticated policy check followed by a short-lived, object-specific presigned download link; keep the game application out of the large-file byte path.

When a game platform exports a replay, texture bundle, or moderation video, the application should authorize the request and get out of the byte path. The operational constraint is that a temporary link is a bearer credential: delivery becomes pleasantly simple, while revocation, audience scope, and leakage become harder. I would therefore issue a short-lived, object-specific URL only after an authenticated export record has passed an authorization check, and I would keep the app responsible for policy, not payload streaming.

The link is the credential.

The choice is less about generating a URL than about deciding which system is allowed to say yes. A storage service can move a 4 GB capture efficiently; it should not decide that a player, tournament staff member, or internal reviewer is entitled to see it.

How should I implement a file export with temporary download links?

A presigned URL normally packages a method, an object identifier, an expiration time, and an integrity-protected signature. Anyone holding it can usually make the permitted request until it expires, so the URL must be treated like a password with a clock attached. It does not authenticate the person clicking it, and expiration is not the same thing as immediate revocation.

That distinction changed how I model export jobs. The API authorizes an export request against a tenant, match, or moderation case, then returns a URL for one immutable result. The storage object remains private, and the browser downloads it directly. The object key is generated server-side from an opaque export ID; accepting a client-supplied path would turn path construction into an authorization bug.

The useful invariant is narrow: a valid link grants access to exactly one export, for one operation, until one stated deadline. It does not grant a listing permission, a bucket permission, or access to neighboring objects.

That is the boundary worth defending.

This boundary is appropriate when the result is already complete and a small authorization service can tolerate bearer-link semantics. It is a poor fit for data that must be revoked instantly, must be watermarked per viewer, or is assembled from many objects under a changing policy. Those cases need a delivery layer that can authenticate each request, or a controlled proxy that accepts the additional bandwidth and on-call burden.

The export path I would put in production

The API should record the export before handing out a link. A minimal state machine might use queued, ready, expired, and revoked; the download endpoint authorizes only ready, and the signer refuses records whose deadline has passed. A worker writes the finished object to a private namespace and stores its size, checksum, content type, and ownership attributes beside the job record.

Here is the important part of the path in Go. The storage adapter is deliberately generic: the policy decision stays testable without coupling it to one provider SDK.

package export

import (
    "context"
    "errors"
    "fmt"
    "time"
)

type Export struct {
    ID        string
    OwnerID   string
    ObjectKey string
    State     string
    ExpiresAt time.Time
}

var ErrForbidden = errors.New("export is not available")

// Signer creates a GET-only URL for a private object.
type Signer interface {
    SignGet(ctx context.Context, key string, ttl time.Duration) (string, error)
}

func DownloadURL(ctx context.Context, callerID string, e Export, signer Signer, now time.Time) (string, error) {
    if e.State != "ready" || e.OwnerID != callerID || !now.Before(e.ExpiresAt) {
        return "", ErrForbidden
    }
    ttl := time.Until(e.ExpiresAt)
    if ttl <= 0 {
        return "", ErrForbidden
    }
    url, err := signer.SignGet(ctx, e.ObjectKey, ttl)
    if err != nil {
        return "", fmt.Errorf("sign export: %w", err)
    }
    return url, nil
}
Enter fullscreen mode Exit fullscreen mode

The code does not attempt to make the link revocable. That is intentional. If revocation is a requirement, the authorization check must happen on every download request, which means the direct-link design has crossed its own boundary. A database delete job is still useful for retention, but deletion is a lifecycle control, not a substitute for access control.

For a browser client on another origin, CORS is a separate concern. The object response needs an allowlist appropriate to the game client, and the API should avoid reflecting an arbitrary Origin. A successful download can work without JavaScript reading the response body, while a client that inspects headers may need explicit exposure rules. MDN's CORS guidance is a good reminder that browser origin policy and storage authorization answer different questions.

Why do simple links fail under real load?

The first failure is usually not cryptography. It is a race between export readiness, URL expiry, and a client retry. If a 4 GB recording takes six minutes to download over a poor connection and the link lives for five minutes, the request can start successfully and still fail during recovery. Set the lifetime against the expected time to start and retry, not just the time needed to mint the URL. Keep it bounded; a week-long bearer token is a policy decision disguised as convenience.

The second failure is observability. The API can log who requested an export, but the storage service sees the signed request, not necessarily the human identity behind it. Record an export ID, caller, object key hash, issuance time, expiry, and policy decision before returning the URL. Do not log the complete query string. A link in an access log is still a credential.

The third failure is content confusion. A filename, content type, and content disposition should come from trusted export metadata, not arbitrary request parameters. Validate the result before marking it ready, and use a checksum or size check so a partial worker upload cannot become a downloadable “complete” artifact. The exact headers vary by storage implementation, but the invariant does not: the object delivered must be the object authorized.

I would put these signals against an SLO that separates authorization from delivery. For example, measure the percentage of authorized-ready requests that receive a URL within the API latency objective, then measure successful byte delivery separately. A storage outage should not be misreported as an authorization outage, and an authorization regression should not hide behind a healthy CDN transfer rate.

Direct storage, a proxy, or a delivery layer?

The honest comparison is about control, not slogans.

Path Access control Delivery simplicity Operational cost Best boundary
Direct signed URL Decision happens before issuance; bearer semantics remain High; storage moves the bytes Low app bandwidth, higher policy discipline Complete, private exports with bounded lifetime
Application proxy Can authorize every request and revoke immediately Low for clients, high for the app Bandwidth, connection handling, and scaling sit with the team Highly sensitive or per-request transformed data
Authenticated delivery layer Can enforce session-aware policy near the edge Medium; more components and cache rules Additional configuration and failure modes Repeated distribution with centralized policy

The underlying products expose different versions of these choices. Amazon S3, Google Cloud Storage, and Azure Blob Storage all document signed or shared-access mechanisms, but their signing fields, credential models, and policy controls differ. Treat those differences as adapter details. The architecture should preserve the same contract: private object, explicit method, narrow scope, bounded expiry, and auditable issuance.

A direct URL wins when the export is immutable and the main requirement is to keep media bytes away from the application fleet. A proxy wins when a policy decision must be made after the click. The middle path is attractive only if the team can own its extra cache, identity, and invalidation behavior; it is not automatically simpler because it sits closer to the user.

Tests and guardrails that matter

Unit tests should reject a non-ready export, a mismatched owner, an expired deadline, and a signer error. They should assert that the object key comes from the stored export record and that the signer receives only the remaining lifetime. Integration tests should fetch a real private test object, verify the allowed method, and exercise a browser-origin request with the intended CORS policy.

Then test the failures that hurt at 02:00: a worker crashes after upload but before the state transition; a client retries with an old URL; a user requests the same export twice; a retention task deletes the object while its record still says ready. The desired behavior should be explicit, including whether the API creates a new link, returns a conflict, or reports that the artifact is gone.

The operational runbook needs one more decision: what does “revoke” mean in this system? Marking the export revoked prevents new links, but it cannot invalidate an already issued bearer URL unless the download is routed through an authorization check or the underlying object is removed. Write that limitation into the contract so incident response does not depend on an assumption the storage layer cannot satisfy.

For game media exports, I would choose the direct path only when the authorization decision is complete before delivery and the result has a clear retention deadline. Keep the object private, sign the smallest possible permission, instrument issuance rather than pretending the URL identifies a user, and make the API own the policy. The link is a delivery credential, not an identity system.

That rule leaves room for a proxy when revocation or per-request transformation matters. It also keeps a successful large-file download from becoming an accidental privilege to browse the storage namespace.

Sources

Top comments (0)