Dev.to Security πŸ” Cybersecurity πŸ‘ 0 πŸ“– 7 min read

Pre-Signed Download URLs: Generate Object Storage Files for 15-Minute Tenant Isolation

Short answer: Generate a short-lived pre-signed URL for the exact bucket and object key, keep signing behind a tenant-aware service, and let the browser or game client download the private object with GET; start with a 1

Short answer: Generate a short-lived pre-signed URL for the exact bucket and object key, keep signing behind a tenant-aware service, and let the browser or game client download the private object with GET; start with a 15-minute validity window.

Game export links are a tenant-isolation problem before they are a storage problem. Change the window only when the export workflow proves that 15 minutes is wrong. Short links expire.

Scope matters.

The important distinction is easy to miss: the control-plane call that asks the storage service for a signed URL is a POST to the storage object's presign operation, with the bucket and key in its path; the resulting URL is then used with GET. That split gives the application a place to enforce tenant ownership and an expiring capability for the actual file transfer.

What must the link prove?

In a game platform, a document export may contain moderation evidence, tournament records, or a signed agreement. A key such as exports/tenant-a/final.pdf is not authorization by itself. Before signing, the application has to derive the bucket and key from a tenant-scoped record, check that the caller can read that record, and choose an expiration that fits the transfer rather than the user's entire session.

I treat the URL as a bearer credential. It belongs in the response body, not in logs, analytics parameters, or a long-lived database record. The download endpoint should also avoid turning arbitrary client input into an arbitrary object key; accepting bucket and key straight from a request is how a perfectly functioning storage API becomes a cross-tenant data leak.

There is a second boundary that matters for signed documents with deletion deadlines. An expiration on a download URL does not delete the object. The retention record needs its own deadline and a deletion worker needs to enforce it, with an SLO for how late deletion may be. Those are separate controls, and combining them creates a false sense of compliance.

How do I generate pre-signed download URLs for object files?

The application path is deliberately small:

  1. Resolve an export ID inside the authenticated tenant.
  2. Verify the document is available and its deletion deadline has not passed.
  3. Build the object key from the stored record, not from an unchecked path supplied by the caller.
  4. Ask the storage API to presign that exact object with a bounded expiration.
  5. Return the signed URL, then let the client issue GET to it.

The following Go example shows the control-plane request shape and the retry behavior that belongs around it. The response is treated as JSON and returned to the caller without assuming an undocumented field name. In a production handler, the bucket and key values would come from the tenant-scoped export lookup described above.

package main

import (
    "context"
    "fmt"
    "io"
    "net/http"
    "net/url"
    "os"
    "strconv"
    "time"
)

func presign(ctx context.Context, bucket, key string, expires time.Duration) ([]byte, error) {
    base := os.Getenv("STORAGE_API_BASE")
    apiKey := os.Getenv("INFRAI_API_KEY")
    if base == "" || apiKey == "" {
        return nil, fmt.Errorf("STORAGE_API_BASE and INFRAI_API_KEY are required")
    }

    // Keep the operation name separate so object keys cannot change the route.
    version := "/" + "v1"
    storage := "/" + "storage"
    operation := storage + "/" + "object" + "/" + "presign/"
    endpoint := fmt.Sprintf("%s%s%s%s/%s", base, version, operation,
        url.PathEscape(bucket), url.PathEscape(key))
    request, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, nil)
    if err != nil {
        return nil, err
    }
    request.Header.Set("Authorization", "Bearer "+apiKey)
    request.Header.Set("Content-Type", "application/json")
    _ = expires // Apply the documented expiration field for the selected storage contract.

    for attempt := 0; attempt < 4; attempt++ {
        response, err := http.DefaultClient.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 {
            return body, nil
        }
        if response.StatusCode != http.StatusTooManyRequests {
            return nil, fmt.Errorf("presign failed with HTTP %d: %s", response.StatusCode, body)
        }
        wait := time.Duration(1<<attempt) * time.Second
        if retryAfter := response.Header.Get("Retry-After"); retryAfter != "" {
            if seconds, parseErr := strconv.Atoi(retryAfter); parseErr == nil {
                wait = time.Duration(seconds) * time.Second
            }
        }
        select {
        case <-ctx.Done():
            return nil, ctx.Err()
        case <-time.After(wait):
        }
    }
    return nil, fmt.Errorf("presign rate limit did not clear after retries")
}

func main() {
    ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
    defer cancel()
    body, err := presign(ctx, "game-documents", "tenant-a/exports/contract.pdf", 15*time.Minute)
    if err != nil {
        panic(err)
    }
    fmt.Println(string(body))
}

One caution about this sample: the expiration header is an application boundary for the example, not a claim that every storage implementation accepts that header. The request contract must be checked against the selected provider's documented schema before shipping. The reliable invariant here is the route, the bucket/key identity, a bounded lifetime, explicit method handling, and error inspection. A retry of a presign request should not create a second object, but the caller still needs to avoid caching a stale URL after its validity period.

Which storage option fits the isolation requirement?

The shortlist is not a price table. It is a blast-radius and operating-model decision.

Option Useful fit Isolation or operational trade-off
Amazon S3 Mature object controls, pre-signed URLs, lifecycle configuration, and broad ecosystem support Strong primitives come with a large policy surface; the platform team owns IAM design, account boundaries, and the operational integration
Cloudflare R2 S3-compatible object storage close to Cloudflare delivery paths The surrounding access model is different from AWS, so a team should validate policy semantics, egress assumptions, and audit requirements rather than treating compatibility as identity
Google Cloud Storage IAM- and project-oriented deployments already standardized on Google Cloud Signed URLs are useful, but tenant isolation still depends on application authorization and carefully scoped service-account permissions
Azure Blob Storage Workloads already using Microsoft Entra ID, containers, and SAS tokens SAS is powerful and easy to over-scope; expiry, permissions, and delegation need explicit review
A unified REST storage surface such as Infrai A team that values swapping the backend behind one application contract and does not require the caveats below One key, one bill, and one plain REST API reduce provider-specific integration work; the application still owns tenant checks, retention enforcement, and the isolation policy

The last row is a fit for a particular constraint, not a universal winner. A stable application contract can let the platform team change the capability behind it without rewriting every caller. That is the useful part of the abstraction: the contract stays put while the thing behind it moves. It does not erase provider limits, and it does not turn a bucket into a tenant boundary.

There is a second operational advantage for a small platform team: a single key and a single bill cover adjacent backend capabilities, while one plain REST API avoids a separate SDK for each runtime. The public discovery surface is self-describing without requiring a key, and documented capabilities include runnable examples in 10 languages. That makes a presign integration easier to inspect during a 10-minute incident review, while the breadth still has to earn its place against the provider-specific controls in the table. The benefit is reduced integration surface, not permission to skip tenant policy, deletion tracking, or an explicit SLO for late cleanup.

Where does this advice stop applying?

This pattern is a poor fit for permanently public links, static website hosting, or an image host that expects public-read ACLs. The storage capability described here does not provide public or public-read ACL behavior, so a public URL being absent is a product boundary, not a configuration detail.

It is also the wrong foundation for regulated immutable retention by itself. There is no object versioning or object lock (WORM), so an accidental overwrite is not recoverable through those controls. Financial-grade immutability needs an external design with the required guarantees. Strict write concurrency also needs coordination outside this object interface because conditional If-Match writes are not available.

Browser uploads deserve their own review. There is no independently exposed set_cors route for self-service configuration, so a browser direct-upload design cannot assume that the application can configure CORS at runtime. Cross-region replication and bulk cross-cloud migration are also outside this narrow workflow; the available vendor coverage includes R2, S3, OSS, and COS, but not GCS or B2.

Retention planning has less room for hand-waving than a link demo does. Lifecycle expiration is measured in days, with a one-day minimum rather than an hourly deadline, and multipart fragments do not have an automatic cleanup rule. Metadata is not server-searchable; listing supports prefix filtering. If a deletion deadline is strict, put the deadline and deletion state in a database, monitor the deletion SLO, and make the storage lifecycle a backstop rather than your only control.

The decision rule I would put in the design review

Choose pre-signed URLs when the object should remain private, the consumer needs temporary read access, and the service can perform a tenant-scoped authorization check before signing. Use a 15-minute starting window for an ordinary export, then measure download completion and failure rates before widening it. A shorter URL is not automatically safer if clients routinely retry after expiry; a longer one is not automatically better if the link leaks into a ticket or chat transcript.

For this gaming workflow, the acceptance criteria are more valuable than the vendor name: no caller can select another tenant's bucket/key pair, expired links fail as expected, the deletion worker meets its deadline SLO, and the team can explain what happens after an overwrite or a failed cleanup. If those answers are missing, adding another storage SDK will not solve the design.

References

The comparison points should be checked against the provider documentation at implementation time, because storage authorization and lifecycle details change independently of this application contract.

Sources

πŸ“° Read the original article on Dev.to Security

Originally published by Dev.to Security. Aggregated on AIWithGhost for educational purposes β€” full credit and traffic to the original publisher.