Private SaaS File Exports: Object Storage Service and Signed Download URLs
Short answer: use an object-storage service only after its signed-download behavior, regional placement, and operational contract pass your application tests; API compatibility is a starting point, not the decision. For
Short answer: use an object-storage service only after its signed-download behavior, regional placement, and operational contract pass your application tests; API compatibility is a starting point, not the decision.
For a SaaS export, the important artifact is not the URL. It is the chain of authority behind it: which tenant requested the file, which object was produced, who may download it now, and what happens when the capability expires. A temporary link is a bearer capability. Treat it accordingly.
Start with the constraint: a link is not authorization
Keep the bucket private and keep authorization in the application. After authenticating the caller, the download endpoint should look up an export record, confirm tenant membership, verify that the export is ready, and only then ask a storage adapter to sign the exact stored object key. The browser should never provide an arbitrary bucket and key for the signer to accept.
The durable record needs an opaque export ID, tenant ID, object key, expected content type, state, creation time, retention policy, and link policy. It should not contain the signed URL. URLs leak through copied support tickets, analytics payloads, browser history, traces, and over-helpful debug logs. Store the fact that a link was issued, not the bearer credential itself.
The URL is not the record.
Expiry and deletion solve different problems. Expiry limits a capability; retention controls the object. A cleanup worker should delete eligible exports and record the result, while legal holds and customer-specific retention exceptions remain explicit states rather than unexplained extensions to every file. Short expiry does not compensate for a missing deletion policy.
Immediate revocation is the boundary that changes the architecture. A direct storage URL cannot re-check application membership on every byte-range request after it has been issued. If revocation must be immediate, put an authenticated application or edge proxy in the download path. The trade-off is additional bandwidth, latency, buffering or streaming work, and another operational component. That is a real cost in reliability terms, even when the URL service itself is well designed.
How should Node.js teams compare signed download links for private exports?
Translate the reader's requirements into a behavioral contract before comparing feature lists. For every required US and EU location, test the same sequence: upload privately, sign a GET, download before expiry, attempt a different object key, exercise an expired link, and inspect the filename, media type, range behavior, and large-file transfer in an actual client. S3 compatibility matters only for the operations and signing semantics the application actually uses.
The production adapter may be written in Node.js, but its contract should be language-neutral. This Python example shows the boundary: callers authorize an export, while the signer receives coordinates from the trusted record.
from dataclasses import dataclass
from datetime import timedelta
from typing import Protocol
@dataclass(frozen=True)
class Export:
export_id: str
tenant_id: str
bucket: str
object_key: str
download_name: str
content_type: str
ready: bool
class ObjectSigner(Protocol):
def signed_get_url(
self,
*,
bucket: str,
object_key: str,
expires_in: timedelta,
download_name: str,
content_type: str,
) -> str: ...
def issue_download(
export: Export,
*,
authenticated_tenant_id: str,
signer: ObjectSigner,
) -> str:
if export.tenant_id != authenticated_tenant_id:
raise PermissionError("export does not belong to this tenant")
if not export.ready:
raise RuntimeError("export is not ready")
return signer.signed_get_url(
bucket=export.bucket,
object_key=export.object_key,
expires_in=timedelta(minutes=10),
download_name=export.download_name,
content_type=export.content_type,
)
The ten-minute value is an example policy, not a universal answer. Test clock skew around the boundary and make the policy depend on the sensitivity and size of the export. A large report that takes longer to transfer needs a different conversation from a small CSV. Your mileage may vary.
Which failure modes matter before a storage comparison?
The difficult failures happen between the database, queue, worker, browser, and bucket. A worker can mark an export ready before the object is readable. A retry can write a new key while the database still points to the old one. Two workers can publish different versions. A browser can receive a generated CSV inline under an opaque UUID because response metadata was never set. None of these is fixed by choosing a familiar API shape. The diagnostic path is longer than the upload call: trace the export ID from the request, through the state transition and queue envelope, into the worker's object write, then through the readiness update and signer input. At each boundary, record the schema version and stable identifiers, while excluding the credential and customer content. Otherwise a retry can look healthy because the queue message exists even though the worker is faithfully consuming an object coordinate that no longer matches the database row.
Use an explicit state transition. The worker writes the object, verifies the final operation, and publishes readiness only after the database record and object coordinates agree. Make retries idempotent at the export-record level. Include a schema version in queue messages, validate it at both ends, and preserve enough structured context to distinguish a payload mismatch from an absent object. Do not put the signed URL in that context.
There is a small but important HTTP detail here. Content-Disposition tells a browser whether content should be displayed inline or treated as an attachment and can provide a filename. The filename and filename* parameters have different encoding implications, so sanitize control characters, test non-ASCII names in target browsers, and let the storage adapter encode response overrides rather than concatenating header text by hand. See the MDN reference below.
Observability should use stable identifiers: export ID, tenant ID, region, operation category, latency, byte count when known, and a normalized outcome. Redact the signed query string. Separate authorization denial, missing export state, object absence, expiry, and cleanup lag in metrics. A generic public error may be appropriate; a generic internal metric is not.
What should the storage comparison record before rollout?
Write down evidence instead of turning βS3 compatibleβ into a conclusion. The comparison belongs after the design constraints, because a service that passes an API demo can still fail the residency, recovery, or browser contract.
| Decision axis | Evidence to collect | Choice-changing boundary |
|---|---|---|
| Regional placement | Complete export and download tests in each required US and EU location | Reject an option without required placement or an acceptable replication design |
| Signed GET behavior | Expiry, key binding, response metadata, clock-skew, and range tests | Reject behavior that forces provider-specific branches in application code |
| Consistency and recovery | Published commitments, read-after-publish tests, restore exercise, and recovery objectives | Choose the contract the team can recover against, not the one with the familiar API |
| Revocation | Deletion, credential rotation, and proxy policy tests | Use an application-controlled path when immediate revocation is mandatory |
| Browser delivery | Attachment disposition, UTF-8 filenames, media types, ranges, and large files | Keep only predictable behavior for target clients |
| Operations | Audit events, lifecycle rules, alerting, support path, and ownership | Reject a system nobody can inspect or rehearse under pressure |
| Cost shape | Stored bytes, requests, retrieval, egress, replication, and commitments | Use workload estimates; a storage-rate headline is not a workload model |
| Portability | Adapter contract tests and an inventory export/import rehearsal | Accept proprietary behavior only when its benefit pays for migration work |
The catch is that no single option wins every row. A self-hosted S3-compatible system can fit a team that already owns capacity, upgrades, disk failure, and recovery drills; it is not suitable when those responsibilities have no named owner. A managed regional service reduces that operational surface, but residency, support, egress, replication, and recovery terms still need review. Stick with an approved existing service when it passes the behavioral contract and migration risk exceeds the gain.
Price belongs in the workload model, not the recommendation. Include storage, requests, retrieval, egress, replication, and minimum commitments, and recheck the published terms before purchase. The Backblaze pricing page is one public example of why those dimensions need to be modeled rather than reduced to a single stored-byte number.
Roll out the adapter without risking the archive
Freeze the adapter contract and golden tests first. Add a per-tenant or per-export routing decision, then run a non-customer fixture through both backends and compare size, checksum semantics, metadata, and download behavior. Move a small internal cohort only after the complete user journey passes: request export, wait for ready, mint the link, download in the intended region, verify filename and content, let the link expire, and observe cleanup.
For existing objects, copy before switching reads. Verify each copy against a manifest, preserve the old database mapping until verification completes, and make the batch resumable and idempotent. If checksum meanings differ, document the comparison method; unlike values do not prove that the bytes differ. Keep source lifecycle deletion paused until the rollback window ends, then enable it through a reviewed change.
Three words: rehearse the rollback.
I would postpone the move if the team cannot state its recovery objective, regional requirement, retention exceptions, or owner for failed cleanup. Those are storage design decisions. A temporary download link cannot make them disappear.
References
Originally published by Dev.to Security. Aggregated on AIWithGhost for educational purposes β full credit and traffic to the original publisher.