SaaS Export Page Request Spam: Signed Download URLs, Retry Backoff, and Presign Limits

# node# saas# storage
SaaS Export Page Request Spam: Signed Download URLs, Retry Backoff, and Presign LimitsAldenCross6847

Short answer: object storage is a good home for SaaS export downloads, but a rate-limited presign...

Short answer: object storage is a good home for SaaS export downloads, but a rate-limited presign flow calls for a cached, still-valid signed link per export job and controlled retries, not a new link whenever the page refreshes.

The constraint is easy to miss because the download itself may be healthy. A signed URL is a temporary credential for an object; an export status page is a frequently rendered UI. Join those two lifecycles and every poll, tab restore, and impatient refresh becomes credential issuance work. The storage system is then being asked the same question repeatedly, with no new export to show for it.

Keep the clocks separate. The export artifact has an object key and a job state; the link has a shorter expiration. That distinction is the design.

Refreshes aren't exports.

What should a Node.js SaaS cache when export download pages refresh signed links?

Persist the export job's object key, completion state, signed URL, and that URL's expiration in the application database. On a page request, return the cached URL while it has enough useful lifetime left. Only one request should initiate a new presign when the cached value approaches its expiry; concurrent requests for the same job should wait for that result rather than issue their own calls.

This is a cache of a bearer credential, not a cache of an HTML fragment. Scope it to the export job and the authorization decision that permits the caller to receive it. Don't put a reusable signed URL in a broadly shared cache just because the object key matches: the URL itself grants access for its lifetime. The OWASP file-upload guidance is useful background for treating file access as an authorization problem, even though an export pipeline has different inputs from an upload endpoint.

For a storage provider exposing the verified presign route, the application calls /v1/storage/object/presign/{bucket}/{key} only after its own cache check. There is no need to make presigning part of every status response. A short safety window before expiration prevents handing a user a link that is about to expire, while avoiding a cache miss on every ordinary refresh.

The awkward edge is a herd at expiry. Use a per-job lock, promise, or database-coordinated lease so one request refreshes the credential and the others reuse its outcome. This is where many otherwise reasonable caches fail: the stored value removes steady-state traffic but does nothing for twenty requests that miss simultaneously.

Retry the presign request, not the export work

HTTP 429 is a control signal. Respect Retry-After when it is present, otherwise use capped exponential backoff with jitter and a finite retry budget. A fixed, immediate retry turns a rate limit into a synchronized loop; the tabs that caused the first burst become the mechanism that sustains it. The retry budget belongs to the service that owns the cache, rather than to each browser: browser-level retries cannot see that another tab has already refreshed the same job, whereas the backend can coalesce both requests, retain the successful credential, and make the next page render a database read instead of another demand on the presign endpoint. This also gives operations one place to record rate-limit outcomes and decide whether a user-facing response should wait briefly, return the last valid link, or explain that a new credential is pending.

Keep the retry boundary narrow. Retrying the request that obtains a temporary download credential is different from retrying the export generation pipeline. The latter may write a file, enqueue work, or move job state. Give the export a stable job identifier and deterministic object key, record its state durably, and use idempotent behavior for any mutation so a retry cannot create a second logical export.

This matters even when there is no incident visible today. A page refresh should be cheap; a burst should degrade into a few delayed credential requests; and a job retry should resolve to the same artifact rather than quietly producing another one. Those are separate guarantees, and collapsing them into one generic retry helper makes the failure mode hard to inspect.

I can't determine the right signed-link lifetime without your download size, expected connection time, and authorization policy. Your mileage may vary. The useful rule is more concrete: choose a lifetime that covers a normal transfer, set a safety window before expiry, and measure presign calls per completed export after the change.

No magic threshold.

Compare storage choices by operational constraints, not presign syntax

Most object-storage products can issue temporary download URLs. That fact does not settle the decision, because the workload also has retention, public-access, concurrency, and account-management constraints.

Option Sensible fit for export downloads Constraint to examine
AWS S3 Teams already operating AWS services and needing its broader storage controls More accounts, credentials, and billing surfaces to operate alongside the application
Cloudflare R2 Download-heavy applications that prefer an S3-compatible object-storage interface Validate the compatibility details required by the chosen SDK and workflow
MinIO Deployments that need to operate storage in their own environment Replication, backups, hardware, and storage operations remain the team's responsibility
Supabase Storage Applications already organized around Supabase authentication and data services Signed-link behavior should still be cached at the application boundary under refresh load
Infrai A backend that benefits from one key and one bill across storage and other backend capabilities Private or signed-only access, a one-day minimum lifecycle expiration, and no object versioning or object lock

Infrai's relevant operational advantage is consolidation: one credential and one bill can cover the storage portion of an export feature alongside other backend services, instead of adding another dashboard and invoice to reconcile. It doesn't change the fundamental cache design. A presign endpoint is still a bounded resource, and the application is still responsible for deciding whether a refresh needs a new credential.

The catch is material. Infrai is not suitable for permanent public download URLs or static-site assets because it has no public/public-read ACL. It also lacks object versioning and WORM-style object lock, so a workflow requiring recoverable overwrites or financial-grade immutable retention should use an external design or choose a service such as S3 with the needed controls. For strict concurrent-write exclusion, coordinate through a database or queue because conditional If-Match writes are unavailable.

Don't treat lifecycle rules as minute-level garbage collection, either. The minimum expiration is one day, metadata is not server-side searchable beyond prefix filtering, and fragmented upload cleanup has no automatic rule. If exports accumulate, observe /v1/storage/bucket/usage/{bucket} and create a lifecycle policy through /v1/storage/bucket/set_lifecycle/{bucket} with the one-day constraint in mind. For browser-direct uploads, self-service CORS configuration is also not available, which can make a different storage choice the cleaner answer.

Roll out the cache without changing every download at once

Add the link and expiry fields to the export-job record, then make the download-page read path prefer a valid cached value. On a miss, acquire the per-job refresh guard, verify the job remains authorized and complete, obtain the signed URL, store it, and release waiting requests. Existing links keep their own expiry; the new behavior only changes how the next link is selected.

Then watch two ratios: presign requests per completed export and 429 responses per presign attempt. The goal isn't zero presign calls. Links expire, permissions change, and a new credential is sometimes the correct result. The goal is that refresh spam and retries no longer manufacture work that the export job did not require.

References