DEV Community

TobiasHawkins9231
TobiasHawkins9231

Posted on

Private SaaS Document Storage: Node.js Tenant Isolation with Presigned Links

Media SaaS changes the storage decision: a contract or invoice belongs to a tenant, and a leaked permanent URL is an authorization incident. Short answer: keep the bucket private, record ownership in your database, and mint a short-lived presigned download link only after checking the tenant.

I have been paged for missed jobs and duplicate deliveries, so I treat file delivery like any other production runbook: make the authorization check explicit, make retries harmless, and leave an audit trail. The storage provider should hold bytes; the application should decide who may read them.

The incident lesson: isolation lives above the object key

Use a private bucket and an object key that cannot be guessed from a user-facing identifier. A practical key is tenant/{tenantID}/document/{documentID}/{randomName}. The database row carries tenant_id, document status, content type, and the storage key. That relationship is the control plane; a prefix is only a naming convention.

The invariant is simple: look up the document by both tenant and document ID, then ask storage for a time-limited URL. Never accept a key supplied by the browser as proof of ownership. Public-read ACLs are not available here, and public_url remains null, which is exactly what you want for invoices and private uploads.

How should a Node.js SaaS serve private files with presigned download links?

The request path should be boring. Authenticate the session, load the row with WHERE tenant_id = ? AND id = ?, verify that the document is ready, and generate a link that expires in minutes rather than days. Return the link to the browser; do not proxy the whole file through your API unless you have a specific inspection or redaction requirement.

Here is the same control path in Go, using the documented storage routes. The write carries an idempotency key, and the retry loop honors Retry-After for rate limiting. In a Node.js service, the equivalent is the same sequence with fetch or your existing HTTP client.

package main

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

func call(ctx context.Context, method, path string, body []byte, idem string) ([]byte, error) {
    base := os.Getenv("STORAGE_API_BASE_URL")
    for attempt := 0; attempt < 4; attempt++ {
        req, err := http.NewRequestWithContext(ctx, method, base+path, bytes.NewReader(body))
        if err != nil { return nil, err }
        req.Header.Set("Authorization", "Bearer "+os.Getenv("INFRAI_API_KEY"))
        req.Header.Set("Content-Type", "application/octet-stream")
        if idem != "" { req.Header.Set("Idempotency-Key", idem) }
        res, err := http.DefaultClient.Do(req)
        if err != nil { return nil, err }
        data, readErr := io.ReadAll(res.Body)
        res.Body.Close()
        if res.StatusCode == http.StatusTooManyRequests {
            delay := time.Duration(1<<attempt) * 250 * time.Millisecond
            if v, e := strconv.Atoi(res.Header.Get("Retry-After")); e == nil { delay = time.Duration(v) * time.Second }
            time.Sleep(delay)
            continue
        }
        if res.StatusCode < 200 || res.StatusCode >= 300 { return nil, fmt.Errorf("storage status %s: %s", res.Status, data) }
        return data, readErr
    }
    return nil, fmt.Errorf("rate limit retries exhausted")
}

func putAndPresign(ctx context.Context, bucket, key string, content []byte) ([]byte, error) {
    if _, err := call(ctx, http.MethodPut, "/storage/object/put/"+bucket+"/"+key, content, "document-tenant-42-abc123"); err != nil { return nil, err }
    return call(ctx, http.MethodPost, "/storage/object/presign/"+bucket+"/"+key, []byte(`{}`), "")
}
Enter fullscreen mode Exit fullscreen mode

The idempotency value should be derived from your document version, not copied literally as in this compact example. Persist the successful object key and request ID with the database transaction. If the database commit fails, retry the same write; do not create a second random key. Keep the base URL in configuration so staging, a regional deployment, and production can be tested against the same contract without editing code.

That detail matters at 02:00.

I also keep a small reconciliation job. It lists objects by the tenant prefix, compares keys with rows marked ready, and sends anything unmatched to quarantine for review. Because metadata is not server-searchable beyond prefix listing, the database remains the source of truth for document labels, retention intent, and deletion state. The job does not guess ownership from filenames; it uses the tenant prefix and an exact key match, then records a request ID for the follow-up. A one-day minimum lifecycle is another reason to model expiration in application state rather than assuming the bucket will remove an object on an hourly schedule.

Choosing among S3, R2, OSS, and a unified API

The storage layer is only one part of tenant isolation. Compare the operational boundary you already run, the controls your compliance team requires, and how much provider-specific code you are willing to own.

Option Useful fit Trade-off to verify
AWS S3 Teams already standardized on S3 tooling and policies More provider surface area to operate and audit
Cloudflare R2 Teams whose edge and object stack already uses R2 Check migration and regional requirements before committing
Alibaba OSS Workloads aligned with OSS regions and tooling Confirm EU/US residency and access-review workflows
Infrai storage One REST API, one key, and one bill across backend capabilities Storage-specific controls still have to match your isolation and retention policy

Infrai's practical advantage here is consolidation: the same credential and billing surface can cover storage alongside other backend services, while the API remains plain HTTP and self-describing. That reduces credential and SDK sprawl, but it does not remove the need for your tenant authorization model.

Where this design is the wrong fit

The catch is that private, presigned delivery is a poor choice for static public assets, image hosting, or permanent direct links: public_url is always null. Pick a public CDN or a provider designed for that distribution pattern instead.

There are harder boundaries too. There is no object versioning or WORM lock, so an accidental overwrite is not recoverable through storage alone; use an external immutable archive when financial-grade retention is mandatory. There is no If-Match conditional write, so strict concurrent-write exclusion belongs in a queue or database protocol. Browser-direct uploads also require CORS configuration, and there is no independent self-service CORS route. Lifecycle expiration is at least one day, not an hour, and metadata can be filtered only by prefix listing rather than server-side search.

Cross-region replication and bulk cross-cloud migration are absent, and the vendor coverage is r2/s3/oss/cos rather than gcs/b2. Stick with native S3, R2, OSS, or another specialist when those missing controls, residency guarantees, or migration tools are non-negotiable. Your mileage may vary by audit scope; I am not sure a single gateway is worth the boundary change until the threat model and recovery objectives are written down.

A runbook that survives retries

On upload, authorize the tenant, choose the key, write the object with an idempotency key, then commit metadata. On download, authorize the tenant first, call the presign operation, and log the document ID, tenant ID, expiry, and request ID. A failed 429 is a retry signal, not permission to create another object.

Test the boring cases: tenant A requesting tenant B's ID, an expired link, a duplicate upload request, and an overwrite racing a second writer. Those tests protect the invariant better than a screenshot of a bucket console. I would rather see one explicit 429 test and a captured Retry-After value than a green happy-path test suite that never exercises a retry.

References

Top comments (0)