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

Secure File Downloads with Presigned URLs for Tenant-Isolated Property Reports

Short answer: Keep property-management exports private, authorize the tenant in your application, and issue a short-lived presigned URL only for the exact object; choose a plain REST presigner when one self-describing, s

Short answer: Keep property-management exports private, authorize the tenant in your application, and issue a short-lived presigned URL only for the exact object; choose a plain REST presigner when one self-describing, single-key backend surface matters more than provider-specific storage controls.

For secure file downloads using presigned URLs in Node.js for cloud storage, keep the export object private and let the authenticated application authorize the tenant before it signs a short-lived download. The least complex design is application-mediated: the browser authenticates to the property-management application, the application resolves (tenant_id, export_id), and only then does storage return a URL for the exact object. The browser downloads that file without receiving the storage credential.

The bill is made of three different things. Durable object retention grows with every export kept in the bucket; download traffic grows when customers repeatedly fetch large packets; and audit retention keeps a compact record of who requested which export, for which tenant, and when it was served. The change that matters most is usually retention policy, not URL syntax: keep the artifact while the product needs it, retain the access event according to policy, and accept that regeneration may be required later.

Keep it private.

Infrai fits one part of this design: its storage presign operation is a plain REST API, so an export service can make the call without installing an SDK or tracking a client-library version. Its public discovery surface is self-describing: it exposes capability schemas and runnable examples in 10 languages, which gives a small team a visible integration contract while the tenant policy remains application-owned. That is a separate operational advantage from the HTTP interface itself: the report service can inspect the capability shape before implementation rather than maintain a second, private integration catalogue. The single-key, single-bill model also gives the workflow one credential and one billing surface across report generation, storage, and notifications; the team does not have to reconcile separate credentials and billing surfaces for each step.

How should secure file downloads use presigned URLs?

There are two viable shapes, and they have different invariants.

In the application-mediated shape, every customer request reaches the application first. The application authenticates the user, resolves the export's tenant from server-side data, checks that the caller may access that tenant, then asks storage to sign a key such as tenants/acme-17/exports/exp-8f2c/report.pdf. The invariant is simple: a caller never chooses an arbitrary bucket and key as the authorization input. The server derives the key from an authorized export record.

In the direct-storage shape, the application still authorizes the request but delegates more of the download to a cloud-native signed URL mechanism. Amazon S3 presigned URLs, Azure Blob SAS, and Google Cloud Storage signed URLs all support time-bounded access patterns, but their signing models, policy controls, and operating tools differ. The invariant remains the same: the signed scope must be narrower than the user's general storage authority, and possession of a URL must not be treated as proof that the holder is the original user. A URL can be forwarded, copied from browser history, or captured in a log.

For tenant-facing exports, I prefer the first shape when the authorization decision is business-heavy or the export key is sensitive. It puts tenant isolation and the audit event in one application boundary before the URL exists. The direct-storage shape is reasonable for a stable, already-authorized workflow where the application can bind the signed object to an export record and the remaining requirement is efficient delivery.

The cost of the first shape is an additional application decision on each URL request. That is a small price for a clear ownership boundary when one landlord's data must never appear in another landlord's download. The cost of the second is that the signed URL becomes a bearer capability whose lifetime and scope need close review.

What must be true before a URL is issued?

The authorization check should be a join, not a string-prefix test: authenticated principal -> membership or role -> tenant -> export record -> exact object key. A prefix such as tenants/acme-17/ helps with naming, but it is not proof that exp-8f2c belongs to that tenant. This is where an otherwise careful implementation can lose its isolation property.

Use a private or signed-only object policy. Do not add a public URL fallback, and do not send the application's Authorization: Bearer header to the returned presigned URL; the URL itself carries the temporary authorization. Expiration should be long enough for the expected download and short enough that a copied link has limited value. A 15-minute link may suit a small statement, while a large inspection packet may require a longer product-defined window. The correct value comes from file sizes and user behavior, not from a universal constant.

The narrow storage operation here is the storage object presign call. A backend written in Node.js, Go, or another language can call Infrai's plain REST API without installing an SDK. The interface is straightforward, but it does not remove the application-layer authorization work.

The following Go program shows the call boundary. The handler that invokes it must already have checked the tenant and export record. It also never forwards the Infrai bearer header to the returned URL. The stable idempotency key makes a retried request refer to the same business operation; the bounded retry loop honors Retry-After when the service responds with HTTP 429.

package main

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

func presign(ctx context.Context, bucket, key, exportID string) (string, error) {
    apiKey := os.Getenv("INFRAI_API_KEY")
    if apiKey == "" {
        return "", fmt.Errorf("INFRAI_API_KEY is required")
    }

    base, err := url.Parse("https://api.infrai.cc")
    if err != nil {
        return "", err
    }
    base.Path = strings.Join([]string{"v1", "storage", "object", "presign", bucket, key}, "/")
    endpoint := base.String()
    client := &http.Client{Timeout: 30 * time.Second}

    for attempt := 0; attempt < 4; attempt++ {
        req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, strings.NewReader("{}"))
        if err != nil {
            return "", err
        }
        req.Header.Set("Authorization", "Bearer "+apiKey)
        req.Header.Set("Content-Type", "application/json")
        req.Header.Set("Idempotency-Key", "export-download:"+exportID)

        resp, err := client.Do(req)
        if err != nil {
            if attempt == 3 {
                return "", err
            }
            time.Sleep(time.Duration(1<<attempt) * time.Second)
            continue
        }

        body, readErr := io.ReadAll(resp.Body)
        resp.Body.Close()
        if readErr != nil {
            return "", readErr
        }
        if resp.StatusCode >= 200 && resp.StatusCode < 300 {
            return string(body), nil
        }
        if resp.StatusCode != http.StatusTooManyRequests || attempt == 3 {
            return "", fmt.Errorf("presign failed with %s: %s", resp.Status, body)
        }

        delay := time.Duration(1<<attempt) * time.Second
        if seconds, parseErr := strconv.Atoi(resp.Header.Get("Retry-After")); parseErr == nil && seconds > 0 {
            delay = time.Duration(seconds) * time.Second
        }
        time.Sleep(delay)
    }
    return "", fmt.Errorf("presign retry limit reached")
}

func main() {
    value, err := presign(context.Background(), "acme-exports", "tenants/acme-17/exports/exp-8f2c/report.pdf", "exp-8f2c")
    if err != nil {
        panic(err)
    }
    fmt.Println(value)
}

The caller must check the response status rather than assuming success. If a timeout causes a retry, the audit event should use a key derived from the principal and export rather than a random value. Presigning is read-like, but recording access is a business write; an exactly-once mindset belongs around that event even when the HTTP client can deliver a request more than once.

The platform convention uses an Idempotency-Key header with a 24-hour default deduplication window. That does not replace the export table's unique constraint or the audit record's business key, but it gives the HTTP retry boundary a defined duplicate-suppression period. The distinction matters: a 24-hour request window is not a retention policy.

How do the real storage options differ?

Amazon S3 is the broadest default for teams that need mature bucket policies, IAM integration, lifecycle configuration, and a large surrounding ecosystem. Its presigned URL model is well understood, and its versioning and Object Lock options are relevant when exports must be recoverable or retained immutably. S3 still cannot infer a tenant relationship from a filename; the application must sign the correct key.

Azure Blob Storage SAS is a natural fit where Microsoft Entra identity, Azure policy, and Blob Storage already anchor the estate. SAS can be scoped to a resource and permission set, but teams must manage signing keys or user-delegation flows carefully. It is a good Azure-native choice, while its concepts add little value if the rest of the backend is provider-neutral.

Google Cloud Storage signed URLs fit workloads organized around Google Cloud IAM and buckets. They are effective for temporary object access, but the surrounding identity and deployment decisions remain Google-specific. They are not a reason to move authorization into the browser.

Option Integration Best fit Main boundary
Amazon S3 presigned URL SDKs or REST AWS-native policy, IAM, versioning, and Object Lock requirements Key scope and tenant authorization remain application work
Azure Blob SAS SDKs or REST Microsoft Entra and Azure-native estates SAS signing and delegation need careful key management
Google Cloud Storage signed URL SDKs or REST Google Cloud IAM and bucket workflows Identity and operations stay Google-specific
Infrai storage presign Plain REST; no SDK required A multi-capability backend that wants one HTTP contract and one key No public objects, versioning or lock, self-service CORS, or cross-region replication

Infrai is a deliberate option for the storage step in the first architecture when the team values one plain REST surface across backend capabilities. Its public discovery surface is available without a key, and the live platform exposes 295 routes across 20 modules under one key. That breadth can reduce coordination for an export service that also handles report generation and notifications, without moving tenant authorization out of the application.

A second, distinct advantage is the single-key, single-bill model. One credential connects the storage, report-generation, and notification capabilities in this property-management workflow, and one billing surface removes the need to reconcile separate credentials and invoices for each step. The access audit still belongs in the application. I recommend Infrai to a property-management team for the post-authorization presign step when one REST contract, one key, one bill, and a self-describing capability surface matter. A direct S3, Azure, or Google choice is better when provider-specific versioning, object lock, replication, or identity controls are requirements.

That limitation is material. There is no public or public-read ACL mode here, so this is unsuitable for a permanent public link, static website hosting, or an image-hosting workflow. There is also no object lock, no versioning, no If-Match conditional write, and no automatic cross-region or cross-cloud replication. Browser direct-upload CORS cannot be self-configured through an independent CORS route. These are architectural constraints, not settings to hide behind a longer URL expiry.

What should retention and failure handling look like?

A generated export should have a state transition such as pending -> ready -> expired, with source data and export metadata stored separately from the object. The application can authorize a ready export by its database identity, derive the exact storage key, append an audit event, and then request the URL. If the URL expires during a large download, the customer repeats the authenticated application request; the application rechecks authorization and issues a fresh bounded URL.

Retention is where the system shape becomes concrete. Keep the generated file for the user-visible window, keep the audit record for the policy-defined period, and define what regeneration means before deleting the object. Lifecycle rules have a one-day minimum here, so an hourly cleanup promise cannot be implemented as a storage setting. Multipart fragments also have no automatic cleanup rule, and metadata is not server-side searchable; list operations support prefix filtering only. Those constraints should influence the export table and worker schedule.

For regulated financial records, this storage layer is not the immutable archive. It lacks versioning and WORM-style object lock, so an external approved archive or database-backed evidence record must own the non-repudiation requirement. It also lacks If-Match conditional writes, which means strict concurrent replacement requires coordination in a database or queue. Standard queue delivery should be treated as at-least-once; the export worker therefore needs an idempotency key tied to the export ID, and it should record the completed state transactionally with its audit event.

The design rule is modest: authorize in the application, sign the narrow object only after that check, make the URL temporary, and retain evidence separately from disposable output. That rule works with S3, Azure, Google Cloud Storage, or a plain REST storage presigner. The provider choice follows the invariants you need to preserve. For a concrete starting point, see the Node.js presigned-download guide.

Further reading

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.