Short answer: put report bytes directly into object storage with a short-lived signed upload URL, but let Node.js and Postgres decide which tenant owns the object and when a signed download URL may be issued.

That rule is useful for a fintech SaaS because the dangerous failure is not a slow upload. It is a valid object becoming visible to the wrong tenant, or a database row claiming that a report exists when the object never finished uploading. The application should therefore treat a signed URL as a temporary capability, not as a document record and not as proof of authorization.

The browser can move the bytes. It cannot choose the tenant boundary.

How can tenant isolation protect Node.js browser uploads, signed URLs, Postgres, and object storage?

The bounded scenario is a customer generating a monthly report while the browser tab is backgrounded. The API has created a pending record and returned a signed upload URL. The tab disappears before the completion request. A different failure has the opposite order: the object transfer succeeds, then the API process loses the response before it records completion. Neither case is unusual enough to justify pretending that object storage and Postgres share a transaction.

I model the document as pending, ready, or rejected, and keep the object key unique per revision. The create operation authenticates the caller, writes the tenant ID, document ID, revision ID, content type, and key in one database transaction, and only then signs the upload. The completion operation checks the object and performs a conditional pending to ready transition. A retry that finds ready can be idempotent; a completion for a different tenant must look exactly like a missing document to the caller.

The invariant is concrete: every downloadable report has one ready row for the requesting tenant and one matching object. A prefix listing cannot establish that relationship. It belongs in reconciliation, where it can find abandoned objects without becoming the authorization database.

One short sentence matters here.

Do not use a customer filename as the storage key. Store quarterly-report.pdf as display metadata in Postgres and use an opaque key containing generated IDs. Otherwise two tenants can collide through a careless overwrite, and a download path that accepts a filename becomes an access-control problem disguised as a routing problem.

Governance: tenant identity is application data

The request path has four distinct decisions. Node.js authenticates the session and validates the requested content type and size policy. Postgres records ownership and reserves a key. The storage adapter signs a narrowly scoped upload URL. The browser sends the bytes to storage and later calls a completion endpoint. For a download, Node.js first selects a ready row by both tenant_id and document_id, then asks the adapter for a short-lived signed download URL.

The URL must not carry the platform credential. The browser receives only the capability for the one object and operation it needs, while the credential used to create that capability stays on the server. A signed URL can be copied during its lifetime, so expiry, object-key secrecy, tenant checks, and audit records still matter.

The storage interface should be small enough to test without a live provider. Here is the state transition I would put behind the Node.js route layer; the Go example keeps the important database predicate visible and leaves URL syntax to the adapter.

package reports import ( "context" "database/sql" "errors" ) var ErrDocumentNotReady = errors.New("document is not ready") type ObjectStore interface { Head(ctx context.Context, bucket, key string) error SignPut(ctx context.Context, bucket, key, contentType string) (string, error) SignGet(ctx context.Context, bucket, key string) (string, error) } type Service struct { DB *sql.DB Store ObjectStore Bucket string } func (s Service) Complete(ctx context.Context, tenantID, documentID string) error { var key string err := s.DB.QueryRowContext(ctx, ` SELECT object_key FROM report_documents WHERE tenant_id = $1 AND id = $2 AND status = 'pending'`, tenantID, documentID).Scan(&key) if err != nil { return err } if err := s.Store.Head(ctx, s.Bucket, key); err != nil { return err } result, err := s.DB.ExecContext(ctx, ` UPDATE report_documents SET status = 'ready', completed_at = CURRENT_TIMESTAMP WHERE tenant_id = $1 AND id = $2 AND status = 'pending'`, tenantID, documentID) if err != nil { return err } changed, err := result.RowsAffected() if err != nil { return err } if changed != 1 { return ErrDocumentNotReady } return nil } func (s Service) Download(ctx context.Context, tenantID, documentID string) (string, error) { var key string err := s.DB.QueryRowContext(ctx, ` SELECT object_key FROM report_documents WHERE tenant_id = $1 AND id = $2 AND status = 'ready'`, tenantID, documentID).Scan(&key) if err != nil { return "", err } return s.Store.SignGet(ctx, s.Bucket, key) } 

The read query is the important line, not the URL helper. The tenant predicate must be present in every read, completion, replacement, and deletion query. Row-level security can add a second barrier, but it should reinforce an explicit service-level authorization rule rather than replace it. Test the negative case: the same document ID under tenant A must never produce a signed download for tenant B.

Code path: make failed transfers visible in Postgres

A pending row is not an error by itself. It is evidence that the workflow has not reached its durable point. Give it a creation time, expected size, checksum when available, and an expiry policy. A cleanup worker can mark an old row rejected and remove its object if one exists; a reconciliation worker can also find objects with no corresponding row. The exact age threshold depends on maximum report size, browser retry behavior, and the service objective for abandoned storage, so I would measure those inputs before choosing it.

Completion should verify more than existence when the application has a reliable expected size or checksum. Content type is useful for product behavior, but it is not an identity check. If the provider supports conditional writes or checksum validation, use those capabilities in the adapter; otherwise, keep the check and the state transition observable so a later reconciliation pass can detect disagreement.

Replacement deserves its own rule. Do not overwrite a ready key while a customer can still download it. Allocate a new revision key, upload it as pending, validate it, and change the current revision in a database transaction. This makes rollback a row change and gives incident response an audit trail.

The operational signals are straightforward: pending rows by age, completion latency, object-check failures, cross-tenant authorization denials, signed-link issuance, and reconciliation backlog. Set an SLO for the time from successful storage transfer to ready; alert on a sustained breach, not on every browser that closes a tab. Capacity planning should include the largest concurrent report set, database write rate, signing request rate, and cleanup throughput.

Reliability signals for interrupted transfers

The choice is a boundary decision, not a URL-format decision. I would compare the options this way:

Design Tenant-isolation control Operational trade-off Choose it when
Direct provider integration Your adapter owns credentials, key policy, and signing behavior More provider-specific code to maintain One provider's controls, regions, or compliance evidence are mandatory
Common storage abstraction The application keeps one authorization and state model while the adapter varies The abstraction may hide provider-specific features The platform team values a small migration surface and can accept the abstraction's limits
Self-hosted object storage The team controls placement, network, and access policy Durability, upgrades, recovery, and capacity become on-call responsibilities Data placement requires it and the team has permanent operating capacity

The common abstraction is not a security boundary on its own. Postgres still owns tenant membership, the application still owns signed-link issuance, and reconciliation still owns the gap between two systems. A direct integration is not automatically safer either; it can be safer when a required provider control is unavailable behind the abstraction.

The catch is that this pattern is unsuitable for an immutable archive unless the selected storage system and retention policy provide the required immutability guarantee. It is also a poor fit for files that must be public forever, because short-lived signed links intentionally provide temporary access. Choose a design with explicit retention, replication, and legal-hold semantics when those are the actual requirement. Don't stretch a private-report upload flow into an archive platform.

Rollout: a storage boundary by on-call ownership

Use browser-to-storage transfer for large private reports, but keep the control plane in Postgres: tenant ownership, revisions, display metadata, status, expiry, and audit events. Sign uploads only after reserving an opaque key. Mark a row ready only after an object check. Sign downloads only after a tenant-scoped ready-row query. Reconcile both directions and measure the lag.

This design does not remove failure; it gives failure a state, a query predicate, and an operator action. That is the useful property. Your mileage may vary on checksum enforcement and retention windows because those depend on the storage API and the report policy, but tenant identity should not vary between them.

References