The operational constraint decides this storage choice: a receipt must be easy for an authorized reviewer to retrieve, but the original file must not become a permanent public URL. Short answer: keep receipt objects private, store an object key plus an audit record in your database, and mint short-lived download URLs only after the application checks the reviewer's access.
That answer is less about picking a famous bucket service than defining two separate paths. Delivery simplicity belongs at the HTTP boundary. Access control, retention, and evidence belong in the application and its audit trail. Mixing them is how a convenient upload demo turns into a compliance cleanup project.
For a notebook-to-prod Python team building a Node.js SaaS, I would evaluate the design with a fixed receipt corpus, an authorization test matrix, and a prompt-cost-aware review of any extraction step. Storage should preserve the evidence; the AI pipeline can produce a derived record that never replaces it.
Receipt retention governance comes before the bucket
Give every upload a new, opaque key. Do not use an email address, claim number, or sequential database ID as the object name. Those values leak business context and make guessing easier. A key such as tenants/acme/receipts/2026/08/<random>.pdf is still useful for operations, while the random component prevents a URL pattern from becoming an index.
The browser can send a small receipt through the application API when validation and malware scanning need to happen in one controlled request. For larger files, a presigned PUT can deliver bytes directly to private storage. That is a delivery decision, not an authorization decision: the application still decides which tenant may create the object and which reviewer may read it.
Store the key, content type, byte count, checksum, uploader, tenant, and a state such as quarantined or accepted in a database row. Store the original object separately from OCR text, extracted totals, and thumbnails. The original is evidence. A parser's output is an interpretation.
The download route should look up the receipt row, check the current user's tenant and role, write an audit event, then mint a short-lived GET URL. The application returns the URL; it does not save the URL as the receipt's identity. A leaked link should have a bounded lifetime, while the key remains a stable reference for later authorization and audit queries.
Three words: key, policy, evidence.
Keep it private.
Here is a deliberately boring Python sketch of the boundary. It leaves the storage client behind an interface so an evaluation can swap an S3-compatible implementation, a local test double, or a gateway without changing authorization logic.
from dataclasses import dataclass from datetime import datetime, timezone from secrets import token_urlsafe @dataclass(frozen=True) class ReceiptUpload: tenant_id: str receipt_id: str object_key: str content_type: str size_bytes: int checksum: str state: str def new_receipt_key(tenant_id: str, receipt_id: str) -> str: """Create an opaque key; the database remains the source of identity.""" nonce = token_urlsafe(18) return f"tenants/{tenant_id}/receipts/{receipt_id}/{nonce}.bin" def make_download_request(receipt: ReceiptUpload, actor: dict, storage) -> dict: if actor["tenant_id"] != receipt.tenant_id: raise PermissionError("receipt is outside the actor tenant") if actor["role"] not in {"auditor", "claims_reviewer"}: raise PermissionError("actor cannot review receipts") if receipt.state != "accepted": raise PermissionError("receipt is not ready for review") audit_event = { "event": "receipt.download_url_issued", "receipt_id": receipt.receipt_id, "actor_id": actor["id"], "issued_at": datetime.now(timezone.utc).isoformat(), } # Persist this event before returning the URL in the real application. record_audit_event(audit_event) url = storage.presign_get(receipt.object_key, expires_seconds=300) return {"url": url, "expires_seconds": 300} def record_audit_event(event: dict) -> None: """Replace this with the application's append-only audit sink.""" pass The pass is a boundary in a small example, not an audit strategy. In production, the event needs a durable sink, an idempotency key, and enough context to answer who accessed which receipt and why. If the event write fails, the safest policy is to withhold the link. A reviewer who sees an occasional retry is less dangerous than a download with no evidence.
The receipt API model makes retries visible
An upload is not complete when the storage service accepts bytes. It is complete when the database can connect an accepted object to one tenant and one receipt, and the application has recorded the validation decision. Use a state transition such as pending -> quarantined -> accepted rather than making the object visible to reviewers immediately.
The commit order matters. Create the database row with a fresh key, upload to that key, verify size and checksum, run the scanner or extraction worker, and then transition the row. If the process dies after the upload but before the transition, a reconciliation job can find an unreferenced object. If the database row exists but the upload never arrives, the row can expire. Both cases are visible. Overwriting one shared key hides them.
Retries need a stable operation identity. A timeout after a successful PUT is not proof that the PUT failed. Retry the same key for the same upload attempt, and make the database transition idempotent. A second user action gets a new key. In my test harness, a repeated commit is a 409-shaped case, not an invitation to replace the current receipt silently.
For direct browser upload, test CORS from the exact production origin, with the actual headers and methods. MDN's CORS guide is a useful reference because the browser enforces the policy; a successful server-to-server request says nothing about whether the browser will allow the response. Do not put a storage credential in browser code. A presigned request grants a narrow operation for a limited period, while the application credential stays on the server.
Multipart upload belongs to the large-file branch, not the default receipt path. The AWS multipart overview documents the create, upload-parts, and complete shape, along with the need to complete or abort an upload. Whatever compatible backend you use, add a reaper for abandoned uploads and test a failed part, a repeated part, and a completion retry. More protocol steps mean more states to observe.
How should a Node.js SaaS choose private object storage for receipt uploads?
Treat the system as three records with different owners:
| Record | Owner | What it must answer |
|---|---|---|
| Original object | Private object storage | Can the bytes be retrieved intact? |
| Receipt row | Application database | Which tenant, status, checksum, and object key are current? |
| Audit event | Append-only audit sink | Who requested access, when, and under which role? |
This division makes a subtle distinction testable. A short-lived URL controls delivery after authorization; it does not replace authorization. A reviewer can forward a live link before it expires, so the expiry window should reflect the UI workflow, not a claim that links are impossible to share. For higher-risk data, stream the file through an authorization gateway or use a shorter window and stronger session controls.
The same distinction applies to deletion. A user-facing delete action can remove the database association while retention policy preserves the original for an approved period. Do not let a generic cleanup job delete evidence merely because a thumbnail or OCR row disappeared. Define retention by record type, tenant policy, and legal hold, then make the cleanup worker emit its own audit events.
I keep the extraction evaluator separate from the storage evaluator. The receipt image is a fixed input; the extraction output gets scored for field accuracy, missing-value behavior, and token use. That separation catches a prompt regression without mistaking it for a storage regression. I'm not sure which will dominate the bill until real receipt sizes and review frequency are measured, so I would not make a provider decision from a single upload benchmark.
The browser is an integration boundary, not an authorization policy
The catch is that this pattern is not suitable when the product requires permanent public URLs, anonymous hotlinking, or a built-in image transformation service. Choose a delivery layer designed for public media when public delivery is the requirement. Do not weaken a healthtech access boundary to make a demo tag load faster.
It is also a poor fit if the storage layer alone must provide immutable legal retention, cross-region disaster recovery, or fine-grained server-side search over receipt metadata. Put those requirements into the acceptance criteria. If the chosen backend cannot provide the required retention control, use a dedicated archive or an application-managed evidence workflow. If the team cannot operate reconciliation and audit sinks, choose a managed architecture with those controls rather than pretending a presigned URL supplies them.
S3 compatibility is a useful portability signal, not proof of feature parity. Compare the details that affect this application: private ACL semantics, presigning inputs, maximum object size, multipart lifecycle, checksum behavior, CORS configuration, retention controls, region placement, and export tooling. Keep that comparison in an executable acceptance test where possible. A row in a feature table is not evidence until the team can reproduce it.
The decision rule is simple. Use the simplest private object store that passes the access, retention, recovery, and delivery tests for your receipt workflow. Stick with a more integrated cloud storage option when its identity, archive, or regional controls are already mandatory. Use a separate archive or gateway when private object storage cannot carry the compliance boundary by itself.
What should the rollout evaluation prove before launch?
Start with one realistic experiment: 100 synthetic receipts across the expected size range, two tenants, an auditor, an unauthorized user, an expired URL, a repeated upload attempt, a failed scanner transition, and one abandoned multipart upload. Keep original files and derived OCR records distinct throughout the run.
Measure more than latency:
- unauthorized reads blocked before URL issuance;
- audit events present for every issued URL;
- duplicate keys and duplicate database commits after retries;
- checksum and byte-count agreement after download;
- abandoned objects and uploads found by reconciliation;
- extraction accuracy and tokens used on the same corpus.
Then run a deletion and retention exercise. Ask what happens when a receipt is removed, a tenant is suspended, or a legal hold appears while a link is still live. Those answers define the product's real security model.
The best storage choice is the one whose failure states remain legible. Private by default, explicit in the database, auditable at the download boundary, and measured with the same corpus you expect in production.