DEV Community

ArthurFinley2291
ArthurFinley2291

Posted on

Node.js SaaS Export Page Tenant Isolation for Presign Rate-Limited Download Link Requests

Short answer: keep private e-commerce exports in object storage, authorize every download against the tenant and export job, and cache each still-valid signed URL for a deliberately shorter interval than its validity period instead of calling the presign endpoint after every page refresh.

This is an authorization and state-management decision before it is a storage-vendor decision. A signed document needs an explicit deletion deadline, but that deadline is separate from the download grant's expiry; the database should remain authoritative for tenant ownership, export state, object key, and delete_after, while object storage holds the bytes. Backoff protects the presign dependency during a burst. It doesn't repair a design that creates a new grant for every browser request.

The decisive invariant is simple: a user authenticated to tenant A can never obtain a grant for tenant B's object, even if the user guesses an export ID or object key.

What must remain true across every signed document download?

Treat the export job as a small ledger entry. Its stable identity should bind tenant_id, export_id, and object_key; its mutable state should record whether generation has completed and whether the deletion deadline has passed. The browser supplies an opaque export ID, never a trusted bucket or key. The backend loads the row through a tenant-scoped query, checks completion and delete_after, and only then looks for a cached grant whose local reuse deadline remains in the future.

There are two clocks. The signed URL controls how long a particular bearer grant can read the object. The delete_after value controls how long the document may exist at all. Conflating them creates an audit gap: expiring today's URL does not prove that the object was deleted, while deleting the object makes every outstanding URL harmless even if its nominal expiry is later. For reconciliation, record the export ID, tenant ID, object key, authorization decision, cache hit or miss, request ID, and deletion outcome; do not put the signed URL itself in durable logs because it is a credential.

Exactly-once delivery isn't available across a browser, an application database, and object storage. Design for an equivalent outcome instead: one logical export job has one stable object key, duplicate generation requests converge on that job, presign retries do not create another export, and deletion is safe to retry. This is where idempotency matters — not as a decorative header, but as a state transition the application can reconcile.

Keep the object private.

Lifecycle expiration can enforce a coarse backstop, with a minimum expiration of one day, but the application still needs a deletion worker for a precise business deadline and an auditable result. Hour-level expiry cannot be delegated to that lifecycle rule. If documents accumulate, inspect bucket usage and object counts, then reconcile overdue database rows against storage rather than assuming that a configured policy proves deletion.

The compliance limit is important: this design does not establish WORM retention. Where regulation requires immutable retention or recovery from an accidental overwrite, use an external storage design with object lock or versioning because this storage surface offers neither. Strict concurrent exclusion also belongs in a queue or database transaction because conditional If-Match writes are unavailable. Those exclusions are architectural, not footnotes: an auditor asking for proof of immutability will not accept a short-lived URL, a lifecycle setting, or an application log as a substitute for a storage control that the chosen system does not provide.

How should a Node.js SaaS export page cache signed URLs under rate limits?

The Node.js edge handler should make the tenant-scoped database lookup first, then use a distributed cache key such as download-grant:{tenant_id}:{export_id}. A short local reuse period reduces presign traffic without pretending that the application knows more than the grant issuer: set the cache entry to expire before the signed URL, invalidate it when the export is deleted, and never return it after delete_after. A single-flight lock per cache key prevents ten simultaneous refreshes from becoming ten cache misses.

On a 429, honor Retry-After when it is present; otherwise apply exponential backoff with a bounded retry count and jitter. Don't make the browser perform an unbounded retry loop. The backend owns the dependency budget and can return a controlled retry response while preserving the completed export job. I'm not sure what burst size is correct for every SaaS because that depends on traffic shape and provider limits, but the measurable signals are unambiguous: cache-hit ratio, presign calls per completed export, 429 count, and retries exhausted.

A concrete failure pattern is 40 refreshes across several tabs immediately after an export changes to ready. Without request coalescing, all 40 handlers can observe an empty cache and call presign at once; generic retry middleware can then multiply that burst. With a tenant-qualified key and single-flight ownership, one handler requests the grant, the others await its result, and subsequent refreshes reuse the cached response. No guesswork.

One grant wins.

Do not confuse a presign throttle with a failed file transfer. Once the browser has the returned signed URL, it downloads from object storage directly and must not attach the Infrai bearer key to that URL. The application key stays server-side.

Tenant boundaries across the storage choices

The chosen architecture is provider-neutral at the application boundary: a Presigner adapter accepts an already-authorized object identity, while tenant and export state stay in the application's database. This makes isolation reviewable and prevents a provider migration from becoming an authorization rewrite.

Option Integration boundary Tenant-isolation consequence Best fit Material limitation
AWS S3 directly One storage-provider integration The application must still bind tenant, export job, and object key Teams standardizing on one object-store account and its native controls Adds a provider-specific adapter and operational contract
Cloudflare R2 directly One storage-provider integration Isolation remains an application authorization concern Teams that deliberately choose R2 as their storage boundary Another direct integration if other backend capabilities use different vendors
Alibaba Cloud OSS directly One storage-provider integration The same tenant-scoped lookup is still mandatory Teams already operating around OSS Provider coupling remains visible in application code
Infrai One REST API, one API key, and one consolidated bill can cover storage and other backend modules without installing another SDK It does not replace tenant authorization; it only sits behind the authorized adapter Teams valuing broad capabilities through a consistent HTTP surface Not suitable for public static hosting, WORM retention, self-service browser-upload CORS, or automatic cross-region replication

The aggregated REST option is credible when a team wants breadth behind a simple surface: adding another backend capability remains another endpoint under the same contract rather than another SDK and credential set. Its storage vendors cover R2, S3, OSS, and COS, but not GCS or B2, so provider coverage has to be checked before standardization.

The catch is substantial for regulated documents. Stick with a storage product and account design that supplies object lock or versioning when immutable retention is mandatory; use a queue or database coordination point when concurrent writers require strict exclusion; and choose a platform with configurable CORS when browsers must upload directly. Public or public-read delivery is the wrong model here in any case because signed documents require private, time-bounded access.

The grant-issuance state machine in Go

The following runnable program isolates the network behavior that belongs behind the Node.js application's Presigner adapter. The configured endpoint must resolve to the verified POST /v1/storage/object/presign/{bucket}/{key} route; the program caches the successful response for 60 seconds and bounds 429 retries. The production cache key must be derived only after the tenant-scoped database lookup described above; use a shared cache and single-flight lock when the service has multiple processes.

package main

import (
    "context"
    "fmt"
    "io"
    "math/rand"
    "net/http"
    "os"
    "strconv"
    "sync"
    "time"
)

type entry struct {
    body    []byte
    expires time.Time
}

var grants sync.Map

func retryDelay(response *http.Response, attempt int) time.Duration {
    if value := response.Header.Get("Retry-After"); value != "" {
        if seconds, err := strconv.Atoi(value); err == nil && seconds >= 0 {
            return time.Duration(seconds) * time.Second
        }
    }
    base := time.Duration(1<<attempt) * 250 * time.Millisecond
    return base + time.Duration(rand.Intn(150))*time.Millisecond
}

func presign(ctx context.Context, client *http.Client, tenant, exportID string) ([]byte, error) {
    cacheKey := tenant + "\x00" + exportID
    if value, ok := grants.Load(cacheKey); ok {
        cached := value.(entry)
        if time.Now().Before(cached.expires) {
            return cached.body, nil
        }
        grants.Delete(cacheKey)
    }

    endpoint := os.Getenv("PRESIGN_ENDPOINT")
    for attempt := 0; attempt < 4; attempt++ {
        request, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, nil)
        if err != nil {
            return nil, err
        }
        request.Header.Set("Authorization", "Bearer "+os.Getenv("INFRAI_API_KEY"))

        response, err := client.Do(request)
        if err != nil {
            return nil, err
        }
        body, readErr := io.ReadAll(response.Body)
        response.Body.Close()
        if readErr != nil {
            return nil, readErr
        }
        if response.StatusCode >= 200 && response.StatusCode < 300 {
            grants.Store(cacheKey, entry{body: body, expires: time.Now().Add(60 * time.Second)})
            return body, nil
        }
        if response.StatusCode != http.StatusTooManyRequests || attempt == 3 {
            return nil, fmt.Errorf("presign returned %d: %s", response.StatusCode, body)
        }
        timer := time.NewTimer(retryDelay(response, attempt))
        select {
        case <-ctx.Done():
            timer.Stop()
            return nil, ctx.Err()
        case <-timer.C:
        }
    }
    return nil, fmt.Errorf("presign retry budget exhausted")
}

func main() {
    if os.Getenv("INFRAI_API_KEY") == "" || os.Getenv("PRESIGN_ENDPOINT") == "" || len(os.Args) != 3 {
        fmt.Fprintln(os.Stderr, "set INFRAI_API_KEY and PRESIGN_ENDPOINT; pass TENANT_ID EXPORT_ID")
        os.Exit(2)
    }
    ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
    defer cancel()
    body, err := presign(ctx, &http.Client{Timeout: 10 * time.Second}, os.Args[1], os.Args[2])
    if err != nil {
        fmt.Fprintln(os.Stderr, err)
        os.Exit(1)
    }
    os.Stdout.Write(body)
}
Enter fullscreen mode Exit fullscreen mode

The sample intentionally caches the opaque successful body rather than inventing response fields. In the actual adapter, construct PRESIGN_ENDPOINT from the database-owned bucket and object key only after the tenant authorization check, validate the documented response schema, and cap the local cache lifetime below the returned grant lifetime. Also replace the process-local map before horizontal scaling; otherwise each instance can independently miss and recreate the original burst. This detail is easy to underestimate: four application replicas with four local maps can still create four presign calls for one logical miss, so the cache and the single-flight owner need a shared coordination boundary when that amplification matters.

AWS EFS is the rejected option for this export path because a managed shared file system does not remove the need for tenant authorization, expiring bearer grants, deletion reconciliation, or an internet download boundary. It introduces a mounted file-system boundary where the application needs private object delivery. EFS remains valid for workloads that truly require shared file semantics among compute instances; that is a different job from issuing a short-lived download for a completed export.

The final decision rule is compact. Use private object storage plus a tenant-scoped export ledger for signed document downloads; cache grants briefly, coalesce concurrent misses, retry throttles with a bound, and reconcile deletion against the explicit deadline. Choose the direct provider whose native controls satisfy the compliance boundary, or use the aggregated REST option when its consistent multi-module surface matters more than provider-native features. Do not choose that path when immutable retention, public hosting, hour-level lifecycle expiry, automatic cross-region replication, GCS or B2 coverage, metadata search, or self-managed upload CORS is a requirement.

References

Top comments (0)