Dev.to Security 🔐 Cybersecurity 👁 0 📖 7 min read

US/EU SaaS: Private File Exports with Object Storage and Signed Download URLs

Short answer: keep exported files private, keep the authorization decision in your SaaS application, and mint a short-lived signed download URL only after the caller is allowed to retrieve that specific object. For US/EU

Short answer: keep exported files private, keep the authorization decision in your SaaS application, and mint a short-lived signed download URL only after the caller is allowed to retrieve that specific object. For US/EU tenants, the hard part is choosing the data boundary and the operational contract; signed URLs are only the delivery mechanism.

The export state machine starts before storage

A finished export is not an authorization decision. That distinction is easy to lose when a worker writes a CSV, PDF, or report and the product immediately stores the resulting URL. The file may outlive a tenant membership, a permission change, or a retention decision. A durable URL then becomes a second credential whose lifetime no longer matches the application policy.

The safer boundary is straightforward: the database records tenant, actor, object key, region, creation time, and retention state; private object storage owns the bytes; the download handler decides whether to issue a link. If authorization fails, no storage URL is created. If it succeeds, the link expires on a deliberate policy boundary.

That separation matters to operations as much as security. Export generation and file retrieval have different traffic shapes, so they need separate dashboards and SLOs. A healthy worker queue says nothing about download latency, and a fast bucket says nothing about an authorization regression. I would track export completion latency, download-handler authorization failures, signed-link issuance latency, object-store status classes, and the rate of expired-link requests. A 403 at the application boundary and a 403 from the storage service are different investigations; preserve that distinction in logs.

Keep the link short-lived.

What makes private SaaS file exports safe across US/EU object storage?

Incident lesson. Consider a bounded production scenario: a user requests a quarterly export, the worker finishes it, and the application returns a link that remains in an email thread for weeks. An administrator later removes that user from the tenant. The export object is still valid, so the old link still works even though a fresh application request should be denied. The queue and object-store health checks remain green. The failure is policy drift, not capacity.

The same class of mistake appears during regional expansion. A worker in the US writes an export for a tenant governed by EU placement rules, a retry sends the job to a second region, and the database stores only an opaque provider URL rather than the region and object key. Later, nobody can prove which copy should be deleted, which logs contain identifiers, or which recovery path is allowed. That is a governance failure with a storage symptom. The remedy is to make placement, retention, and recovery properties part of the export record and to test them as deployment policy, rather than hoping an S3-compatible API will infer them.

The invariant I use in a review is that an object key is an identifier, never permission. It should be scoped to the tenant and difficult to guess, but obscurity is not the access control. Every download request must resolve the export record, verify tenant ownership and actor permissions, check retention, and only then ask storage to sign the object.

For direct browser downloads, set the response metadata deliberately. Content-Disposition can tell the browser whether to display content inline or treat it as an attachment, and its filename handling deserves tests for quotes, Unicode, and path separators. The storage URL should not be assembled from an unchecked filename.

The Go contract for a revocable download

The link is not the policy.

Start with the policy matrix, not a feature-count spreadsheet. For each tenant, decide where the object is written, whether a worker may read across regions, how long the export survives, and what happens after a user loses access. Then verify that the chosen storage contract can implement those decisions without making the application pretend that replication, versioning, or immutable retention exists when it does not. The handler should be tested with a revoked membership, a deleted export, an object in the wrong region, a repeated request, a malformed filename, and a signer response that arrives after the application timeout. Those cases are more valuable than another happy-path upload test because each one exercises a different authority boundary and produces a different operational signal.

A minimal request path looks like this:

package main

import (
    "context"
    "errors"
    "net/http"
    "time"
)

var errDenied = errors.New("download denied")

// StorageSigner is deliberately small so the policy stays in the application.
type StorageSigner interface {
    SignGet(ctx context.Context, bucket, key string, expires time.Duration) (string, error)
}

func downloadHandler(signer StorageSigner, lookupExport func(string) (string, string, bool), allowed func(*http.Request, string) bool) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        if r.Method != http.MethodGet {
            http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
            return
        }

        bucket, key, ok := lookupExport(r.PathValue("exportID"))
        if !ok || !allowed(r, bucket+"/"+key) {
            http.Error(w, errDenied.Error(), http.StatusForbidden)
            return
        }

        url, err := signer.SignGet(r.Context(), bucket, key, 10*time.Minute)
        if err != nil {
            http.Error(w, "download unavailable", http.StatusBadGateway)
            return
        }

        http.Redirect(w, r, url, http.StatusFound)
    })
}

func main() {}

The example intentionally leaves tenant lookup and signer implementation behind interfaces. Those are the places where policy and provider details belong. In a real service, validate the export ID, derive the object key from the database record, and never accept a bucket or key supplied as an authorization shortcut by the browser. The ten-minute value is an example policy, not a universal answer; large downloads, email workflows, and sensitive reports may need different lifetimes.

For retries, distinguish a failed object write from a failed link issuance. Use an idempotency key for export publication, bound retry counts, and honor Retry-After on HTTP 429 responses. A retry loop that turns temporary pressure into a larger burst is an availability bug in the application, even when the storage service is behaving correctly.

US and EU are deployment constraints, not a complete data-residency design. Record the selected region with the export, prevent accidental cross-region reads, and state whether backups, logs, and support access follow the same rule. The available source material does not establish automatic cross-region replication, so I would require an explicit provider contract and a restore test before treating replication as an SLO dependency.

The operating model follows every byte

A managed object store usually buys you durable byte storage and a signing primitive. It does not buy tenant authorization, a retention policy, a correct deletion workflow, or a useful incident history. Building the storage layer yourself gives more control over policy and placement, but it also creates another system for on-call engineers to operate, patch, capacity-plan, and restore.

Decision area Managed object storage Self-hosted object storage
Access control Application policy plus provider signing Application policy plus your signing and key management
Capacity planning Forecast bytes, request rate, egress, and regional headroom Also forecast disks, repair bandwidth, nodes, and failure domains
SLO ownership Split between application and provider contract Entirely owned by the platform team
Portability Check S3-compatible semantics, metadata, and lifecycle differences Control the implementation, carry migration and operations cost
Retention Verify lifecycle, deletion, and immutability semantics Implement and test those controls yourself

I would make the decision with the export size distribution, peak download concurrency, regional placement rules, recovery point objective, recovery time objective, and on-call budget in hand. I'm not sure a generic compatibility badge answers the important questions: test multipart behavior, signed-request expiry, response headers, partial failures, and deletion visibility against the exact client library and gateway you will run.

The catch is that S3 compatibility is a useful portability target, not proof of identical behavior. A design that depends on versioning, object lock, conditional writes, or cross-region replication must verify each capability directly. If the platform cannot meet a required control, select a storage service that can, or add a separate system that owns that control.

Where signed downloads stop being appropriate

This pattern is not suitable when the requirement is anonymous, permanent public delivery, static website hosting, or a high-volume media distribution path where a CDN should own caching and token policy. Stick with a public hosting or CDN architecture when public access is genuinely the product requirement. Do not make a private export bucket carry that job by weakening its authorization boundary.

It is also a poor fit when users need collaborative, continuously mutable documents rather than immutable export artifacts. A signed GET can authorize a download; it does not provide record-level sharing, conflict resolution, audit-friendly edits, or revocation stronger than the URL lifetime.

For ordinary SaaS exports, the durable design is deliberately boring: generate the file, write it under a tenant-scoped key, persist the export record, authorize each request, issue a temporary link, and measure the whole path. Boring is useful here.

References

📰 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.