Short answer: Put retention ahead of vendor selection for private health uploads: enforce revocation and verified deletion in the application, generate short-lived upload and download URLs only after authorization, and keep object keys out of client control.
The simplest object storage is not the one with the shortest SDK example. In a healthtech system, the decisive question is whether an expired record becomes unreachable and is then deleted on schedule. A private bucket and a presigned URL help with access, but neither is a retention policy by itself.
My default is boring: one metadata record owns the object lifecycle, every signed operation starts from that record, and a reconciliation job checks that database state and storage state agree. This costs more engineering time than handing permanent object URLs to the frontend. It also gives compliance and support teams a concrete answer when they ask what remains after a user deletes an upload.
Keep that boundary sharp.
How should a Node.js SaaS test private user upload object storage?
Treat the metadata row as the control plane and the object store as the byte plane. The row should contain an opaque object key, owner ID, content type, expected size, retention deadline, lifecycle state, and timestamps for requested and confirmed deletion. It should not contain a reusable download URL. A URL is a temporary capability, so persisting one in a profile record, log line, analytics event, or email quietly extends its practical exposure beyond the screen that requested it.
Four invariants carry most of the design. First, a client cannot choose another tenant's object key. Second, signing a read requires a fresh authorization decision against the owning record. Third, crossing the retention deadline makes new reads impossible even if physical deletion is queued. Fourth, deletion is complete only when both the metadata transition and an object-store check say so. That last distinction matters because "delete requested" and "object absent" answer different audit questions. The failure boundary belongs in the design, too. If upload completion is never recorded, the object is an orphan and should be swept after a conservative grace period. If metadata says an object is active but the object is absent, reads should fail closed and raise an integrity signal. If a deletion worker is retried, deleting an already absent key should converge on the same deleted state. Don't let retries resurrect metadata or issue another read capability. There is an awkward edge case around replacement. An avatar-like UI encourages overwriting one stable key, but a private clinical intake image may be under retention or legal-hold rules. Use a new opaque key for each accepted version, switch the active pointer in metadata, and retire the prior version according to its own policy. The visible concept can be "the current file" while the storage model remains immutable. This also avoids a cached response showing bytes from a different version under the same path. CORS is narrower than many teams expect. It controls which browser origins may read a cross-origin response; it does not authenticate the user or make a public object private. Configure only the application origins, methods, and headers required by the browser upload, and test the preflight path. Server-to-server signing remains an authorization decision in your application.
Comparing Data Paths Against Deletion Evidence
The three common shapes differ less in upload syntax than in who controls the bytes and where failure is recorded.
No waiver.
| Shape | Data path | Retention evidence | Main trade-off |
|---|---|---|---|
| Direct signed transfer | Browser sends bytes to a private object store | Application metadata plus storage reconciliation | Lowest application bandwidth, but completion and orphan cleanup need explicit design |
| Application proxy | Browser streams bytes through the API | One service can validate and record the transfer | Clear control point, but the API carries bandwidth, backpressure, and timeout risk |
| Managed upload service | Browser follows a service-specific flow | Depends on export, deletion, and audit interfaces | Less transfer code, but lifecycle semantics and portability require close review |
For ordinary health document and profile uploads, direct signed transfer fits when the storage service supports the required signing operations and the team can run reconciliation. The application creates the object key, constrains the operation, records an upload_pending state, and returns a short-lived upload capability. After transfer, the client asks the application to finalize; the application verifies the stored object before moving the row to active. Reads repeat authorization and retention checks before minting a new short-lived download capability.
The catch is that this option is not suitable when bytes must pass through synchronous inspection that cannot run after direct upload, or when policy forbids client-to-storage network traffic. Use an application proxy in those cases and budget for streaming limits, cancellation, and backpressure. A managed upload service can fit a small team when its deletion evidence, residency controls, and export path satisfy the same written invariants. No architecture gets a waiver.
S3 compatibility is useful because it can narrow the application boundary, but the label alone does not prove identical behavior for signing, conditional operations, checksums, multipart cleanup, lifecycle rules, or consistency. I'm not sure a compatibility claim is sufficient until the exact operations in the critical path pass contract tests against the candidate service. Your mileage may vary at the edges; resolve that uncertainty with tests, not a feature-grid checkmark.
Encoding the Retention Gate
The following Python sketches an application service around a generic storage port. A Node.js SaaS can keep the same boundary; the language is incidental. Notice that callers provide a record ID, never an object key, and that an expired record is denied before any download URL is created. The store adapter is the only component that knows a provider's signing API.
from dataclasses import dataclass from datetime import datetime, timedelta, timezone from typing import Protocol from uuid import uuid4 @dataclass class UploadRecord: id: str owner_id: str object_key: str content_type: str expected_bytes: int retain_until: datetime state: str class StoragePort(Protocol): def sign_upload(self, *, key: str, content_type: str, expected_bytes: int, expires_in: timedelta) -> str: ... def verify_object(self, *, key: str, expected_bytes: int) -> bool: ... def sign_download(self, *, key: str, expires_in: timedelta) -> str: ... def delete(self, *, key: str) -> None: ... def exists(self, *, key: str) -> bool: ... def begin_upload(repo, store: StoragePort, actor_id: str, content_type: str, size: int, retain_until: datetime): if size <= 0 or retain_until <= datetime.now(timezone.utc): raise ValueError("invalid upload policy") record_id = str(uuid4()) record = UploadRecord( id=record_id, owner_id=actor_id, object_key=f"private/{actor_id}/{uuid4()}", content_type=content_type, expected_bytes=size, retain_until=retain_until, state="upload_pending", ) repo.insert(record) url = store.sign_upload( key=record.object_key, content_type=record.content_type, expected_bytes=record.expected_bytes, expires_in=timedelta(minutes=10), ) return {"record_id": record.id, "upload_url": url} def create_download(repo, store: StoragePort, actor_id: str, record_id: str): record = repo.get(record_id) now = datetime.now(timezone.utc) if record.owner_id != actor_id or record.state != "active": raise PermissionError("download denied") if now >= record.retain_until: raise PermissionError("retention period ended") return store.sign_download(key=record.object_key, expires_in=timedelta(minutes=2)) Those ten-minute and two-minute values are example policy inputs, not universal recommendations. Set them from measured transfer times and the shortest usable access window. A signed upload that expires too quickly creates retries and abandoned objects; a download capability that lives too long increases the time in which a copied URL can be used. The right numbers depend on file sizes, user networks, and the sensitivity of the material.
Finalization must be idempotent. It verifies size and expected metadata, changes upload_pending to active once, and rejects a key mismatch. For larger objects, multipart upload can improve transfer behavior, but incomplete multipart uploads consume storage until completed or stopped; the AWS S3 multipart overview explicitly calls out the need to complete or stop an initiated upload. If you enable multipart transfer, track its upload identifier and add stale-upload cleanup to the same reconciliation run. Start with single-operation uploads when file limits permit. Fewer states are easier to audit.
Prove it.
Exercising the Deletion Race
At the retention deadline, atomically move the row from active to delete_due. From that moment, the read path refuses to sign. A worker then asks storage to delete the opaque key, checks for absence, records deleted_at, and changes the state to deleted. Retry the worker with bounded backoff, and alert on age in delete_due rather than on a single failed attempt. The compliance metric is the oldest overdue undeleted object, not merely the number of worker errors.
This is where a concrete state machine earns its keep: upload_pending -> active -> delete_due -> deleted, with upload_pending -> abandoned for transfers that never finalize. Legal hold can be a separate state transition that blocks delete_due; it should not be an undocumented boolean sprinkled through handlers. Record who applied the hold, why, and when it is reviewed.
Test time as a dependency. Freeze the clock immediately before, at, and after retain_until; assert that no download is signed at the boundary. Run contract tests that upload a uniquely marked small object, finalize it, authorize a read, expire it, delete it, and verify absence. Also test tenant swaps, repeated finalization, repeated deletion, an expired signing capability, a disallowed content type, a mismatched byte count, and browser preflight from an unapproved origin. The happy path is the least interesting part.
Observability must avoid becoming another disclosure path. Log record IDs, state transitions, policy versions, latency, and normalized failure categories. Do not log signed URLs, query strings, raw object keys, filenames supplied by users, or health content. Keep audit events separate from high-volume request logs, define their own retention, and restrict access. A dashboard should expose pending-upload age, orphan count, deletion lag, reconciliation mismatches, and signing attempts denied by policy.
Deployment deserves the same caution. Add the metadata schema and dual-read compatibility before enabling new states, deploy workers before scheduling deadlines, and canary the store adapter's contract suite in each region. Rollback must preserve deletion intent. If an older application version cannot understand delete_due, it should fail closed rather than reinterpret the row as active.
Fail closed.
The Proxy Exception
I reject public buckets and permanent bearer links for private health uploads because application authorization cannot reliably revoke a capability that never expires. Hiding an unpredictable URL is not access control. I also reject using storage lifecycle expiration as the only control record: lifecycle automation can perform deletion, but the application still needs to deny reads at the policy boundary and retain evidence that reconciliation observed the outcome.
The rejected proxy design has a valid use case. Stick with a streaming application proxy when every byte must undergo inline transformation, malware inspection, or a network control that direct transfer cannot satisfy. It can also simplify browser CORS because the browser talks only to the application origin. The cost is operational: your service now owns upload bandwidth, stream cancellation, memory limits, request deadlines, and partial-transfer cleanup. Make that choice because the control is required, not because proxy code looks familiar.
A storage decision is ready when a team can demonstrate the whole lifecycle in a disposable environment: create metadata, sign an upload, finalize, authorize an expiring private download URL, cross the retention boundary, deny new reads, delete, and reconcile absence. Put those checks in CI for every S3-compatible adapter under consideration. Selection then rests on verified semantics and operational fit rather than the length of a quick-start snippet.

