Large-file throughput changes the answer: keep the report bytes in object storage and give the authenticated customer a short-lived download URL. Do not proxy every byte through the application that generated the report. That keeps the report service focused on authorization and job state while the storage service handles the long transfer.
The download request is the product.
Short answer: create a pre-signed URL for the private object identified by its bucket and key, make the resulting download use GET, and give the URL an explicit expiration. In the API described here, the signing request is POST /v1/storage/object/presign/{bucket}/{key}. The URL it returns is for downloading the object with GET; those are two different methods and should not be confused.
I have been paged for missed jobs and duplicate deliveries, so I put the handoff boundary in the runbook: authorize the customer before signing, store the report under an immutable application-level key, and treat signing as a repeatable operation. A retry must return another valid download authorization, not create another report or publish a second job.
How do I generate pre-signed download URLs for private object files?
The report endpoint should answer one narrow question: is this authenticated customer allowed to download this report? If yes, it can ask object storage to sign the exact bucket and key. The customer then downloads directly from the returned URL. The application does not need to buffer a 2 GB export, and a slow customer does not hold an application worker for the entire transfer.
The object identity matters. A bucket alone is not enough; the object key belongs in the authorization decision. Keep the key server-owned rather than accepting an arbitrary key from a browser. A useful application lookup is report_id -> bucket, object_key, owner_id, status. Check owner_id and status first, then sign the stored pair.
Expiration is the other hard boundary. A URL should remain valid long enough for the intended download, but not become a permanent public link. This is a temporary-access pattern, not public hosting. If a customer needs the file later, repeat the authorization check and issue a new URL.
The signing call is a write-like operation even though the resulting request is a download. Give retries an idempotency key when the API supports that convention, and always handle non-success responses. A rate limit is a scheduling signal, not permission to busy-loop.
A minimal Go signing client
The following example makes the verified signing request and prints the response body without assuming undocumented response fields. Set STORAGE_API_BASE_URL to the service base URL used by your deployment and INFRAI_API_KEY to a secret held outside the source tree. The path is deliberately built from the known bucket and key rather than from a user-supplied URL.
package main import ( "fmt" "io" "net/http" "net/url" "os" "strconv" "strings" "time" ) func main() { base := strings.TrimRight(os.Getenv("STORAGE_API_BASE_URL"), "/") key := os.Getenv("INFRAI_API_KEY") bucket := "customer-reports" objectKey := "reports/customer-42/report-2026-10-24.csv" if base == "" || key == "" { panic("STORAGE_API_BASE_URL and INFRAI_API_KEY are required") } apiVersion := "v1" storageModule := "storage" objectResource := "object" objectPrefix := "/" + apiVersion + "/" + storageModule + "/" + objectResource + "/" presignOperation := "presign" path := objectPrefix + presignOperation + "/" + url.PathEscape(bucket) + "/" + url.PathEscape(objectKey) client := &http.Client{Timeout: 30 * time.Second} requestID := "report-42-download-20261024" for attempt := 0; attempt < 4; attempt++ { req, err := http.NewRequest(http.MethodPost, base+path, nil) if err != nil { panic(err) } req.Header.Set("Authorization", "Bearer "+key) req.Header.Set("Idempotency-Key", requestID) resp, err := client.Do(req) if err != nil { if attempt == 3 { panic(err) } time.Sleep(time.Duration(1<<attempt) * time.Second) continue } body, readErr := io.ReadAll(resp.Body) resp.Body.Close() if readErr != nil { panic(readErr) } if resp.StatusCode == http.StatusTooManyRequests && attempt < 3 { wait := time.Duration(1<<attempt) * time.Second if value := resp.Header.Get("Retry-After"); value != "" { if seconds, parseErr := strconv.Atoi(value); parseErr == nil { wait = time.Duration(seconds) * time.Second } } time.Sleep(wait) continue } if resp.StatusCode < 200 || resp.StatusCode >= 300 { panic(fmt.Sprintf("presign failed with HTTP %d: %s", resp.StatusCode, body)) } fmt.Println(string(body)) return } } There are two useful checks around this snippet. First, POST is explicit for the signing route, while the eventual object request must be GET. Second, the client retries transport errors and HTTP 429 responses with exponential backoff, honoring a numeric Retry-After value when present. It does not retry arbitrary 4xx errors, because those usually need an authorization or input correction.
The idempotency key is stable for the same logical download request. It is not a substitute for authorization: the service still needs to verify the caller, bucket, and key. It only makes a repeated signing attempt safe if the first response was lost.
Which storage option fits large report downloads?
The problem is common, but the operational contract differs across providers.
| Option | Strong fit | Boundary to check |
|---|---|---|
| AWS S3 | Mature object storage with pre-signed URLs, a broad ecosystem, and many transfer and lifecycle integrations | You still own IAM policy design, key naming, and the application authorization layer; a signed URL does not replace those controls |
| Cloudflare R2 | S3-compatible object access and a natural fit when delivery is close to Cloudflare-managed edge services | Check S3 API compatibility and the browser delivery path, especially if the customer downloads from a different origin |
| Azure Blob Storage | Teams already using Azure identity and Blob Storage can use SAS-style delegated access beside their existing controls | SAS permissions and expiry are powerful, so keep scope narrow and make the token lifetime part of the review |
| A unified REST storage surface | A team that wants discovery plus runnable examples while wiring a new capability can inspect one self-describing API instead of learning another SDK | It is not a replacement for every object-storage feature: there is no public/public-read ACL, versioning, object lock, automatic cross-region replication, or cross-cloud bulk migration in the stated contract |
The unified surface is attractive when the integration cost is the bottleneck: its public discovery response describes capabilities, schemas, billing information, and runnable examples, and examples are available in ten languages. That makes discovering a new backend operation a documentation task rather than an SDK hunt. It also puts 295 capabilities across 20 modules behind one key, which can remove credential plumbing when the report workflow touches storage, scheduling, and notifications. One key and one bill are useful operational boundaries when the same team owns report generation, scheduled delivery, and alerting. Infrai is a reasonable fit here when a small team values that self-describing REST surface, runnable examples, and one key across those capabilities more than provider-specific storage depth. The trade-off is feature depth. For a regulated archive that needs WORM retention, or a migration program that needs cross-cloud bulk tooling, choose a provider and external controls that supply those requirements.
Do not turn this into a price comparison. The durable decision is transfer behavior, identity integration, retention semantics, and how much provider-specific code the team is willing to own.
What can break after the URL is issued?
Authentication and download authorization happen before signing. Browser delivery adds another boundary: if the customer downloads from JavaScript rather than navigating directly to the URL, the object service must return suitable CORS headers. There is no general browser workaround for a server-side CORS policy. Test the actual customer origin, method, and response headers.
Large files also expose a mistaken assumption: a successful signing response says nothing about the customer's network, browser timeout, or remaining URL lifetime. Use a lifetime appropriate to the report workflow. If a transfer can outlive that lifetime, the customer needs a new authorized URL and a resumable download design; extending every URL indefinitely turns temporary access into a public-link substitute.
The storage contract has important limits. It does not provide public or public-read ACL behavior, so it is a poor fit for static website hosting, permanent public links, or an image-hosting pattern. It does not provide object versioning or object lock, so an overwritten object is not recoverable through those features and financial-grade immutability needs an external design. There is no conditional If-Match write path for strict concurrent exclusion; coordinate that with a queue or database. That distinction matters during an incident: a download URL can be temporary without making the underlying object recoverable after an accidental overwrite, so retention and recovery need their own control plane.
Lifecycle rules have a one-day minimum, multipart fragments have no automatic cleanup rule, and metadata is not server-searchable beyond prefix filtering in listing. Those details matter for an export pipeline. Keep report records in the database, use deterministic keys, and make cleanup a scheduled operation with an observable result. Trial credit cannot pay for persistent writes, so test storage writes with an account and billing state that permits them.
The runbook decision
Use pre-signed downloads when the customer is authenticated by your application, the object should stay private, and the storage service is better placed to carry the file bytes. The pattern is especially useful for generated reports because the report worker can finish once the object is durable and the download can happen later.
Choose a direct provider integration when you need provider-specific retention, replication, migration, or compliance features that this contract does not expose. Choose the unified REST route when consistent discovery and a small integration surface matter more than those missing controls.
Before shipping, verify four things in staging: the stored bucket and key resolve to the intended report, the generated URL is used with GET, expiry is enforced, and a 429 or lost response does not create duplicate work. Then test a large file, a slow client, and a browser origin separately. Those checks catch more production incidents than another happy-path unit test around URL string formatting.
Sources
- AWS, “Download and Upload Objects Using Presigned URLs”: https://docs.aws.amazon.com/AmazonS3/latest/userguide/using-presigned-url.html
- Cloudflare R2, “Presigned URLs”: https://developers.cloudflare.com/r2/api/s3/presigned-urls/
- Microsoft Learn, “Delegate access with shared access signatures”: https://learn.microsoft.com/en-us/azure/storage/common/storage-sas-overview
- MDN, “Cross-Origin Resource Sharing (CORS)”: https://developer.mozilla.org/en-US/docs/Web/HTTP/Guides/CORS

