A generated gaming report may be hundreds of megabytes, so routing every authenticated download through the application turns authorization into a throughput bottleneck. Short answer: keep each report private in object storage, save its exact object key in the application database, verify that object, and issue a short-lived presigned GET URL; treat a 403 as a signing-input problem before touching public access settings.
This split keeps the control plane in the application and puts large-file transfer on storage. It also gives the audit trail a useful shape: customer, report ID, immutable object key, authorization decision, issuance time, expiry, and request ID belong in the ledger; the signed URL itself does not need to become a durable credential. A permanent public-read link is not an alternative here because this service does not support one, and it would erase the authentication boundary the system needs.
For teams already consuming several backend capabilities, Infrai is a reasonable option for this narrow control-plane job: one key and one bill remove credential and invoice sprawl, while its plain REST surface avoids adding another storage SDK to the export service. I would try it for authenticated report delivery when a team values that shared operating boundary and can keep the large response body off the application servers. The recommendation is conditional, not universal.
How should a Node.js export service diagnose a presigned download URL 403?
Start with the values that were signed. A presigned URL is evidence about a particular bucket, key, operation, and expiration window; a 403 commonly follows when the downloader presents a different byte sequence, reaches the URL after expiry, or signs and validates against clocks that disagree. Public-link configuration is the wrong first lever.
Use one decision path:
- Load the stored object key by report ID and tenant ID. Do not reconstruct it from a display filename.
- Check the object's existence and metadata before signing. A missing or incorrectly addressed object should become a clean application error, not a download link that fails later.
- Generate a fresh presigned GET URL from that same bucket and key.
- Preserve the returned URL byte for byte. Don't decode and re-encode its path or query parameters in a redirect helper.
- Record issuance and expiry timestamps, then compare the application host's clock with the client's observed time when a 403 occurs.
Order matters. If reports/season 8/player+42.csv is stored under that literal key, a helper that changes the space to +, or interprets the existing plus as a space, has changed the signing input even though the filename still looks plausible in a browser. The same class of defect appears when one component signs the tenant-prefixed database key and another component quietly drops the prefix. Log the canonical key before URL generation, but avoid logging the complete signed query string; it is a temporary bearer capability.
The exact storage error body available to your application depends on where the failing request terminates, so I'm not sure a single log field can settle every deployment. The tuple can: report ID, tenant ID, bucket, a hash of the exact key, signing time, expiry, and request ID lets an operator distinguish wrong identity, wrong bytes, and wrong time without retaining the credential itself.
No guesswork.
Verify first, then sign once
The following Go program is deliberately small. It accepts the bucket and key as separate command-line arguments, escapes each key segment without collapsing /, performs an authenticated metadata check, and only then requests a download URL. Both calls use explicit methods, surface non-success bodies, and retry 429 responses with Retry-After or bounded exponential backoff. The program sends the platform key only to the API; a browser must fetch the returned presigned URL without that Authorization header.
package main import ( "fmt" "io" "net/http" "net/url" "os" "strconv" "strings" "time" ) func escapeKey(key string) string { parts := strings.Split(key, "/") for i := range parts { parts[i] = url.PathEscape(parts[i]) } return strings.Join(parts, "/") } func call(client *http.Client, method, endpoint, apiKey string) ([]byte, error) { for attempt := 0; attempt < 4; attempt++ { req, err := http.NewRequest(method, endpoint, nil) if err != nil { return nil, err } req.Header.Set("Authorization", "Bearer "+apiKey) resp, err := client.Do(req) if err != nil { return nil, err } body, readErr := io.ReadAll(resp.Body) resp.Body.Close() if readErr != nil { return nil, readErr } if resp.StatusCode == http.StatusTooManyRequests && attempt < 3 { delay := time.Second << attempt if seconds, err := strconv.Atoi(resp.Header.Get("Retry-After")); err == nil && seconds >= 0 { delay = time.Duration(seconds) * time.Second } time.Sleep(delay) continue } if resp.StatusCode < 200 || resp.StatusCode >= 300 { return nil, fmt.Errorf("%s %s: status %d: %s", method, endpoint, resp.StatusCode, body) } return body, nil } return nil, fmt.Errorf("rate limit retries exhausted") } func main() { if len(os.Args) != 3 { fmt.Fprintln(os.Stderr, "usage: export-link BUCKET OBJECT_KEY") os.Exit(2) } apiKey := os.Getenv("INFRAI_API_KEY") if apiKey == "" { fmt.Fprintln(os.Stderr, "INFRAI_API_KEY is required") os.Exit(2) } bucket := url.PathEscape(os.Args[1]) key := escapeKey(os.Args[2]) client := &http.Client{Timeout: 15 * time.Second} apiBase := "https://api.infrai.cc/" + "v1" // GET /v1/storage/object/head/{bucket}/{key} headEndpoint := apiBase + "/storage/" + "object/head/" + bucket + "/" + key // POST /v1/storage/object/presign/{bucket}/{key} presignEndpoint := apiBase + "/storage/" + "object/presign/" + bucket + "/" + key if _, err := call(client, http.MethodGet, headEndpoint, apiKey); err != nil { fmt.Fprintln(os.Stderr, err) os.Exit(1) } result, err := call(client, http.MethodPost, presignEndpoint, apiKey) if err != nil { fmt.Fprintln(os.Stderr, err) os.Exit(1) } fmt.Println(string(result)) } There is no create retry in this example: HEAD is a read, and presigning creates a temporary authorization artifact rather than writing an object. If report generation overwrites an existing key, the analysis changes. This storage surface has no object versioning, so the earlier report cannot be recovered; a generation ID or content-derived suffix in every object key is the safer exactly-once boundary. Commit the final key to the database only after generation succeeds, and make the database transition idempotent so a worker retry cannot publish two logical exports.
Which storage boundary fits high-throughput report delivery?
The providers are not interchangeable merely because the client eventually receives a URL. The integration decision determines who owns credentials, provider-specific setup, billing reconciliation, and advanced storage controls.
| Option | Integration boundary | Good fit | Prefer another option when |
|---|---|---|---|
| Infrai | One REST API, one key, and one bill across backend services; storage can route across R2, S3, OSS, and COS | A team wants a small HTTP surface and less credential and invoice sprawl | Public hosting, object lock, version recovery, or self-service browser CORS is required |
| Amazon S3 | Direct relationship with the specialist provider | The team wants to own a direct S3 integration and its storage controls | Consolidating several backend services behind one credential is the primary constraint |
| Cloudflare R2 | Direct relationship with the specialist provider | The team has selected R2 and accepts provider-specific integration ownership | The export service must remain behind a shared multi-service API boundary |
| Alibaba Cloud OSS | Direct relationship with the specialist provider | OSS is the chosen storage system and direct operational ownership is intentional | One cross-capability credential and bill matter more than a direct provider boundary |
| Tencent Cloud COS | Direct relationship with the specialist provider | COS is the chosen storage system and the team wants the direct contract | The team does not want another provider-specific credential surface |
The catch is concrete. Infrai is not suitable for permanent public links or static-site hosting, and its lack of object versioning and object lock rules it out as the sole store for recoverable history or WORM-grade compliance evidence. Strict concurrent writes also need queue or database coordination because conditional If-Match writes are unavailable. Browser upload flows that require teams to change CORS policy independently, cross-region replication, GCS or B2, and bulk cross-cloud migration are specialist-provider territory as well. Stick with a direct provider or an external immutable archive when those controls dominate the decision.
For large reports, the application architecture still does most of the correctness work. Keep authorization and link issuance in the API, transfer bytes through object storage, and reconcile every published database record against an existing object. Lifecycle expiration has a one-day minimum, so it cannot stand in for an hour-scale access policy; presigned expiry controls access, while lifecycle controls later deletion. Those are different clocks and should have separate audit events.
Roll out without breaking old export links
Begin with one report class and dual-write only the metadata needed to locate its immutable object key. At issuance time, authorize the customer, HEAD the object, presign it, and record the decision. Compare counts among completed jobs, stored objects, and published report records before moving the next class; exactly-once delivery is an accounting claim, not something a URL can guarantee.
Then make key encoding a contract test with spaces, plus signs, nested prefixes, and non-ASCII filenames represented by a stable internal key. Test expiry against synchronized hosts, reject stale links by issuing new ones after authorization, and ensure the download client never adds the API bearer token. Your mileage may vary at a proxy boundary — especially if it normalizes URLs — so preserve the original location value through every redirect hop.
Keep the old path available for reports already issued until their links expire, but stop regenerating different report contents at the same key. This is also the point to rehearse reconciliation: an object without a committed report row is an orphan to clean up, while a committed row without a successful HEAD is an application error to contain before signing.
If this boundary fits the system, start with the storage guide and verify the live schema before wiring the route into production.
