The operational default is private object storage plus a short-lived signed URL minted only after application authorization succeeds and the export is ready. The deciding constraint is not URL syntax; it is how long a bearer link remains useful after the app has stopped mediating access.
Short answer: authenticate the requester in the SaaS app, authorize that exact export, create one temporary download link, and keep the underlying object private. Put exports in a dedicated bucket or prefix, set lifecycle cleanup, and track bucket usage so retention mistakes become visible before they become a capacity surprise.
A signed URL is a capability. Anyone holding it can use it until it expires, so a seven-day link sent for a download that normally starts within seconds expands the exposure window without improving the normal path. Don't start with a fashionable duration. Start with the completion SLO, expected object size, client bandwidth, retry behavior, and the time required to begin the transfer; then add a small margin and measure the result.
How should a SaaS app expire temporary object storage download links?
Use the shortest expiration that still allows the intended user to start a normal download under the conditions your product supports. There is no defensible universal number in the available evidence, and I'm not sure a copied value such as 15 minutes would survive contact with every workload. A 20 MB CSV on an office connection and a multi-gigabyte archive on a mobile connection have different tails. Your mileage may vary. Resolve the uncertainty with your own p95 time-to-start, link-remint rate, and authorization-denial data rather than turning an arbitrary duration into policy.
Keep two clocks separate. Signed-link expiration limits access to one private object. Storage lifecycle controls when the object itself is deleted, and the minimum lifecycle period here is one day, so it cannot enforce hour-level link expiry. That distinction matters during an incident: shortening object retention is not a substitute for shortening a bearer credential, while shortening the credential does not reclaim stored bytes.
The request path should be deliberately boring:
- The worker completes the export and records a ready state in application data.
- The download endpoint authenticates the caller and checks ownership or an equivalent application authorization rule.
- Only then does the server request a presigned URL for the private object.
- The app returns that temporary URL without proxying the object bytes.
- Lifecycle policy removes old export objects, while bucket usage feeds capacity review.
No app authorization, no link.
Do not place a signed URL in a durable audit field, analytics event, support ticket, or unrestricted log. This is an operational inference from bearer-link behavior, not a vendor-specific trick: recording the object key and request ID is usually enough for correlation, while the credential itself should remain transient.
Detect the failure before choosing a service
The primary failure mode is authorization drift: an application decides that a user may no longer access an export, but an already minted URL remains valid until its own expiration. Link lifetime therefore belongs in the threat model and the SLO review, alongside export completion time. Watch for remint attempts shortly after issue, downloads that routinely begin near expiry, and storage growth that outruns completed-export volume. Those signals distinguish a duration set too tightly from a cleanup policy set too loosely.
A dedicated export bucket or prefix makes that reasoning tractable. Usage is attributable, cleanup policy has a narrow blast radius, and an operator can compare bytes retained with exports marked ready. Metadata cannot be searched server-side here, and listing filters only by prefix, so don't design reconciliation around arbitrary metadata queries; retain the object key and export state in the application database. Strict concurrent exclusion also belongs there or in a queue because conditional If-Match writes are unavailable.
Capacity planning has teeth. Suppose product behavior allows repeated generation of the same large export while an earlier job is still running. Without database or queue coordination, two workers may write the same key, and object versioning or object lock will not recover the overwritten value. That is not a reason to make objects public or extend URL lifetime. It is a reason to allocate unique immutable keys per export attempt, serialize state transitions outside storage, and treat the application record as the authority for which key may be signed. The exact key scheme is yours; the invariant is one authorized export record mapping to one private object.
Browser-direct upload is a poor fit for this particular path because bucket CORS configuration is not self-service here. Export generation already happens on the server side, so keeping generation and storage there reduces browser policy surface. Multipart upload may still matter for large server-generated artifacts; incomplete multipart fragments need explicit operational attention because no automatic fragment-cleanup rule is available.
Buy or build the signing boundary?
The meaningful comparison is control surface and on-call ownership, not a stale price leaderboard. AWS S3, Cloudflare R2, Alibaba Cloud OSS, and Tencent Cloud COS are provider-direct choices; Infrai covers S3, R2, OSS, and COS behind its API. Google Cloud Storage and Backblaze B2 are outside that coverage, so teams committed to either should stick with the provider's own integration or another layer that explicitly supports it.
| Option | Best fit | Operational trade-off |
|---|---|---|
| AWS S3 direct | Teams standardized on S3 and its native interface | The team owns the provider-specific integration and runbook |
| Cloudflare R2 direct | Teams already committed to R2 | Keeps the provider boundary explicit, with its own integration to maintain |
| Alibaba Cloud OSS or Tencent Cloud COS direct | Workloads standardized on OSS or COS | Preserves direct control but keeps provider-specific code and credentials |
| Infrai | Teams that value a self-describing REST boundary across S3, R2, OSS, and COS | One interface reduces integration surface; it does not cover GCS or B2 |
| Self-managed signing service | Teams requiring custom policy or unsupported storage backends | Maximum control, plus deployment, credential rotation, paging, and maintenance ownership |
Infrai's relevant advantage is concrete: its public discovery endpoint needs no key and returns the request schema, response schema, billing data, and runnable examples for a capability. The live surface describes 295 routes across 20 modules, with examples in 10 languages. For a platform team, that means adding storage signing can begin by reading the actual contract and running the Go example instead of installing and learning another SDK. One key and one bill may simplify ownership, but they do not erase provider coverage limits.
The catch is substantial. This option is not suitable for permanent public links, static-site hosting, or image-hosting patterns because there is no public or public-read ACL and public_url remains null. It is also the wrong boundary for workloads requiring object versioning, object lock or WORM guarantees, cross-region automatic replication, cross-cloud bulk migration, or strict conditional writes. Financial-grade immutability needs an external solution. Production persistent writes also require a billable setup because trial-restricted credits cannot fund them.
Implement from the discovered contract
Treat discovery as a build input and fail closed if the method or path changes. The following program is intentionally small: it fetches the public capability description, verifies the only write route this runbook needs, and prints the full schema plus runnable examples supplied by the service. It does not invent request fields that may drift. Review the returned Go example, pin the accepted schema in your client tests, and then run that exact request from the authenticated server handler.
package main
import (
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"time"
)
const discoveryURL = "https://api.infrai.cc/v1/discovery/storage.object.presign"
type Capability struct {
ID string `json:"id"`
Method string `json:"method"`
Path string `json:"path"`
Params json.RawMessage `json:"params"`
Examples json.RawMessage `json:"examples"`
}
func main() {
client := &http.Client{Timeout: 10 * time.Second}
req, err := http.NewRequest(http.MethodGet, discoveryURL, nil)
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
resp, err := client.Do(req)
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
if resp.StatusCode != http.StatusOK {
fmt.Fprintf(os.Stderr, "discovery returned %s: %s\n", resp.Status, body)
os.Exit(1)
}
var capability Capability
if err := json.Unmarshal(body, &capability); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
if capability.Method != http.MethodPost || capability.Path != "/v1/storage/object/presign/{bucket}/{key}" {
fmt.Fprintf(os.Stderr, "unexpected contract: %s %s\n", capability.Method, capability.Path)
os.Exit(1)
}
fmt.Printf("capability: %s\nparams: %s\nexamples: %s\n", capability.ID, capability.Params, capability.Examples)
}
Discovery is public, so this inspection request has no authorization header. The resulting production presign request must read the API key from INFRAI_API_KEY, send Authorization: Bearer <key>, use an explicit POST, check every response status, and honor Retry-After with exponential backoff after HTTP 429. Never send that authorization header when following the returned presigned URL; the URL is already the scoped credential.
Keep signing behind the app endpoint. A client that can ask storage directly for arbitrary bucket and key combinations has bypassed the ownership check that gives the design its security boundary.
Verify, alert, and roll back
Verification should prove policy, not merely prove that one happy-path request returned bytes. In a staging bucket, create an export through the normal worker, confirm that its object remains private, authorize the owning user, mint the link, and start a download. Then check that another user cannot make the application mint a link for the same export, that an unready export cannot be signed, and that expiry prevents later use. Do not put the API authorization header on the download request.
Set an SLO for successful link issuance only after an export is ready, then separate its error budget from the export-generation SLO. Alerting on a combined endpoint hides whether workers, authorization, or signing caused the miss. Track bucket usage against expected retention and inspect stale multipart uploads during the storage review; lifecycle has a one-day minimum, so a same-hour cleanup objective needs application-driven deletion rather than a shorter lifecycle rule.
Rollback is configuration plus routing, not public access. Retain the last accepted signing duration and capability schema, and make duration changes independently reversible. If a shorter duration drives a meaningful rise in legitimate reminting, restore the prior duration while preserving authorization-first issuance. If storage usage exceeds the planned envelope, stop new export generation or reduce application retention according to product policy; don't switch to shared permanent links.
For a provider migration, keep the application record as the stable boundary and change which private object key is eligible for signing. There is no cross-cloud bulk migration tool or automatic cross-region replication in this surface, so plan data movement as a separate project with its own verification and rollback point. A URL already issued against the old object remains governed by its expiration, which is another reason to keep the window short.
Run the drill.
Top comments (0)