Short answer: for signed healthtech documents, keep the application server in the authorization path but keep file bytes out of it; issue a short-lived presigned upload for a server-allocated tenant key, confirm the object separately, and enforce deletion with a recorded deadline. Choose a storage backend only after it can express that isolation, browser CORS policy, private signed download, and retention process.
The storage brand is secondary. The invariant is that a browser may transfer bytes directly, but it must never decide which tenant namespace those bytes enter. The application authenticates the user, allocates an immutable key, and grants one bounded operation. The object store receives the bytes. A database remains the authority for document ownership and deletion status.
This is an architecture decision record, not a provider leaderboard. A direct upload removes a data hop; it does not remove authorization, lifecycle, or audit work.
The deletion deadline is the primary design input
For a signed document, I would use a private bucket or equivalent private namespace, with keys shaped like tenants/{tenant_id}/documents/{document_id}/original.pdf. The client gets the key from the application, never invents it. The grant should be limited to that key, the expected method, and a short expiry. The application stores the document row before issuing the grant and changes its state only after an independent confirmation of the completed object.
There are four boundaries worth writing into the ADR:
- Identity belongs to the application. A storage policy must not infer tenant identity from an untrusted filename or browser field.
- Byte transfer belongs to storage. The application issues permission but does not proxy the file in the normal path.
- Business state belongs to the database. “The browser received a successful response” is not the same as “the signed document is accepted.”
- Deletion belongs to a deadline-driven worker with an audit record. A lifecycle rule is useful, but it is not the whole proof that a tenant document was removed from the product’s reachable state.
The key must be unique even when two browser tabs upload a file called agreement.pdf. Reusing a human filename creates an overwrite decision where the product usually needs a new document version. For signed material, accidental replacement is a data-integrity event, not a cosmetic nuisance.
| Design choice | What it buys | Boundary to test |
|---|---|---|
| Browser to private object storage | No application-server byte hop and a smaller upload path | The grant cannot escape its tenant key or expiry window |
| Application-issued presigned upload | Authorization stays with the application while storage handles bytes | A user cannot request an arbitrary bucket, prefix, or content policy |
| Immutable document keys | Concurrent uploads do not silently replace one another | Database uniqueness selects the accepted document reference |
| Signed private download | A document is available for a bounded read without becoming public | The response preserves the intended filename and expires as designed |
| Deadline worker plus lifecycle rule | Product state and storage cleanup have explicit owners | Retries are idempotent and missed work is observable |
The rejected shortcut is “put the tenant ID in the browser request and trust it.” That is not isolation. It is user input with a storage-shaped costume.
How should storage handle browser direct upload without a proxy?
Treat these as different controls. Browser direct upload describes the network path. A presigned upload describes a temporary authorization. CORS describes which browser origins may call the storage endpoint; it is not an access-control replacement. A signed download grants a later read and should be generated only after the document is authorized by the application.
The browser sequence is deliberately boring:
- Authenticate against the application and request an upload for a document that belongs to tenant 42.
- Have the application allocate a document ID and object key, persist the deadline, and return a narrow upload grant.
- Send the bytes from the browser to storage using the grant, with the exact headers that the CORS policy permits.
- Ask the application to confirm the object. The application checks the expected key and records acceptance.
- For a later read, ask the application for a signed download only after checking tenant membership and document state.
The important security detail is that CORS is a browser permission mechanism, while the presigned URL is an operation grant. A permissive origin list can make an application easier to call from the browser; it cannot turn an unauthorized storage key into an authorized one. Test the real production origins, methods, and request headers with a preflight request, then test the actual upload from a browser rather than stopping at a configuration diff.
The download response also needs product semantics. If a user should see signed-agreement.pdf instead of an opaque object key, set and verify the response disposition deliberately; the HTTP Content-Disposition header controls whether content is shown inline or treated as an attachment, and it can carry a filename. The standard is more useful here than a provider comparison because the requirement is visible to the user at the final HTTP response.
A tenant isolation test matrix
The comparison should be made against acceptance tests, not a feature checklist. A backend that looks compatible with a familiar object API may still differ in its CORS controls, European placement, retention behavior, or signed response headers. Record the result for the exact region and workload.
| Acceptance test | Pass condition | Failure consequence |
|---|---|---|
| Tenant key allocation | The client cannot choose a destination outside its server-issued tenant key | Cross-tenant write or overwrite risk |
| Browser preflight | Every production origin and required header is allowed, and unrelated origins are rejected | Upload works in a script but fails or becomes too permissive in the browser |
| Private signed download | The application checks tenant membership before minting a time-limited read | A valid URL becomes an unintended sharing mechanism |
| European placement | The selected region and replication behavior match the deployment requirement | Data governance and recovery assumptions diverge |
| Deadline deletion | Repeated worker runs converge and leave an auditable result | A product promise says “deleted” while an object remains reachable |
This table is also the practical answer to “which storage should we choose?” Pick the option that passes the required rows with the least operational machinery your team can actually own. A lower request price cannot compensate for a missing deletion proof or an isolation policy that is difficult to test.
Critical path in Python
The signing call is adapter-specific, so the domain code should not pretend that every object store has the same SDK or policy vocabulary. Keep the state machine independent, then put one small adapter behind the interface that creates the provider’s presigned operation.
from dataclasses import dataclass from datetime import datetime, timezone from enum import Enum from uuid import UUID, uuid4 class DocumentState(Enum): GRANT_ISSUED = "grant_issued" UPLOADED = "uploaded" ACCEPTED = "accepted" DELETION_DUE = "deletion_due" DELETED = "deleted" @dataclass(frozen=True) class DocumentUpload: document_id: UUID tenant_id: int object_key: str delete_at: datetime state: DocumentState def allocate_document(tenant_id: int, delete_at: datetime) -> DocumentUpload: document_id = uuid4() object_key = f"tenants/{tenant_id}/documents/{document_id}/original.pdf" return DocumentUpload( document_id=document_id, tenant_id=tenant_id, object_key=object_key, delete_at=delete_at.astimezone(timezone.utc), state=DocumentState.GRANT_ISSUED, ) if __name__ == "__main__": document = allocate_document( tenant_id=42, delete_at=datetime(2026, 12, 31, tzinfo=timezone.utc), ) print(document.object_key) That function does not upload anything, and that is intentional. It creates the server-owned identity and the deletion deadline before a storage adapter signs the operation. In production, the transaction should also record who requested the document and which policy version produced the grant. The confirmation endpoint must accept the known document ID, not a replacement key supplied by the browser.
Deletion needs the same discipline. At the deadline, mark the row as due, issue an idempotent delete for its exact key, verify the storage response according to the backend contract, and record completion. If the worker crashes after the delete but before the database update, a retry must converge on the same state. If the object is missing, the worker should make that outcome explicit rather than quietly declaring that every deletion path succeeded.
Short grants help. They do not fix a wrong key.
Where direct upload stops being appropriate
Tenant isolation fails in several unglamorous ways. A prefix check that accepts tenant-42 when it intended tenant-4 is a policy bug. A download endpoint that checks a document ID but not its tenant relationship is an authorization bug. A retry that generates a new key after a network timeout can leave an unreferenced signed document behind the deadline worker. A CORS rule that allows the upload method but omits a required request header fails only in the browser, which makes it easy to misdiagnose as a signing problem.
Write tests for those boundaries, not only for a happy-path 200 response. Use two tenants and assert that tenant A cannot obtain an upload grant for tenant B’s key. Replay an upload request and assert that it does not replace an accepted document. Advance the clock beyond the deadline and run the deletion worker twice. Exercise preflight and actual upload from every production origin. Verify that a signed download cannot be minted after the document is marked deleted.
Observability should expose identifiers without exposing document contents: tenant ID, document ID, grant issuance, confirmation, deletion attempt, deletion result, and the policy decision. Keep object keys out of user-facing logs where they could become accidental access hints. Alert on overdue deletion work, repeated confirmation failures, and an unusual rise in unreferenced objects. Those signals describe control-plane drift before a storage invoice or a complaint reveals it.
There are real limits to the direct approach. The limitation is architectural: it is not suitable when every byte must be inspected or transformed before durable storage, when an organization forbids browsers from receiving storage grants, or when a regulatory workflow requires a single controlled ingestion service. Choose a bounded streaming proxy for those cases and accept that its capacity, timeout, and retry model become part of the system.
It is also a poor fit if the chosen backend cannot provide the required European placement, private signed reads, configurable browser CORS, or a retention mechanism that matches the deletion promise. When one of those controls is absent, choose a direct provider integration with the documented control instead of adding application-side guesses. Pricing should be compared only after those gates pass: request volume, stored bytes, transfer, region, and download behavior can change the result, and the current public pricing documentation is the place to validate the workload model rather than a static ranking.
The rejected proxy and its valid exception
The normal design rejects proxying every upload through the application. The application already has to authorize the operation and maintain the document record; moving the bytes through it adds a second transport path and a second capacity limit without improving tenant identity by itself. Direct transfer is the cleaner boundary when the storage grant is narrow and the object remains private.
The exception is a policy that genuinely requires inspection, encryption transformation, malware scanning, or a controlled ingress point before storage. In that design, the proxy is not an embarrassing fallback. It is the enforcement point, and it needs explicit backpressure, bounded request sizes, resumability decisions, and failure recovery.
Your mileage may vary on deletion semantics, especially where a backend’s retention and versioning model differs from the product’s promise. I’m not sure a procurement checklist can settle that question. A small adversarial test against the selected region and the actual tenant policy can.
The decision rule is therefore straightforward: select the backend and deployment region that can enforce the tenant boundary, expose the browser controls, support private signed reads, and meet the deletion contract; then measure cost and developer convenience inside that feasible set. A fast upload is still a failed design if one tenant can address another tenant’s signed document.

