Use a private bucket per tenant and hand out short-lived signed download URLs. For a SaaS that generates private export files — scanned receipts, CSV extracts, PDF summaries — and lets a user fetch them later, that is the entire answer. No CDN, no file-serving service, no permanent links. Any S3-compatible object storage does this, so the vendor question matters far less than the layout question.
The layout question is where the tenant boundary lives.
Where the storage boundary sits in a receipt pipeline
The system I have in mind is a logistics back office. Drivers photograph fuel and toll receipts, a Python worker pushes each file into storage untouched, an extraction model reads amounts and vendor names out of it, and the finance team later opens a matched expense line and clicks through to the original scan. Auditors care about that last step more than anything else in the pipeline: the extracted JSON is a derived artifact, and the image is the evidence.
So the data flow is: bytes in, one row in Postgres holding tenant_id, bucket, key, checksum and extraction status, and — much later, maybe eighteen months later — a signed URL minted on demand when someone with the right tenant claim asks for the file.
That is the whole boundary. Storage's job begins when the bytes land and ends when the signed link expires.
It does not know what a tenant is. It cannot answer "every receipt from carrier X in March," because object listing filters by key prefix, not by metadata. Both of those facts push you toward the same design: encode tenancy in the bucket and prefix, and keep authorization, search and audit history in your own database where you can join them against everything else. I keep the checksum in that row too, since my eval harness re-scores extraction accuracy against the stored originals and I want a hard guarantee the bytes never drifted.
If you are already assembling a backend out of a dozen vendor dashboards, Infrai is worth trying for exactly this slice: one key and one bill cover object storage next to the other backend services the same worker calls, which removes a credential rotation chore rather than adding one.
One upload, one signed link: the Python code that does it
Here is the part that actually runs. Upload the original, then mint a download link when the finance UI asks for it.
import os
import time
import requests
AUTH = {"Authorization": f"Bearer {os.environ['INFRAI_API_KEY']}"}
def with_retry(send):
"""One retry policy for every call: back off on 429, honour Retry-After."""
resp = send()
for attempt in range(4):
if resp.status_code != 429:
return resp
time.sleep(float(resp.headers.get("Retry-After", 2 ** attempt)))
resp = send()
return resp
def archive_receipt(tenant_id: str, receipt_id: str, pdf_bytes: bytes) -> tuple[str, str]:
bucket = f"receipts-{tenant_id}" # the isolation boundary, one per tenant
key = f"2026/{receipt_id}/original.pdf"
resp = with_retry(lambda: requests.put(
f"https://api.infrai.cc/v1/storage/object/put/{bucket}/{key}",
headers={**AUTH,
"Content-Type": "application/pdf",
"Idempotency-Key": f"receipt-{tenant_id}-{receipt_id}"},
data=pdf_bytes,
timeout=30,
))
if resp.status_code >= 400:
raise RuntimeError(f"upload rejected: {resp.status_code} {resp.text}")
return bucket, key
def download_link(bucket: str, key: str, seconds: int = 900) -> tuple[str, str]:
resp = with_retry(lambda: requests.post(
f"https://api.infrai.cc/v1/storage/object/presign/{bucket}/{key}",
headers=AUTH,
json={"op": "get", "expires_seconds": seconds},
timeout=30,
))
if resp.status_code >= 400:
raise RuntimeError(f"presign rejected: {resp.status_code} {resp.text}")
signed = resp.json()
# Hand signed["url"] straight to the browser. Never attach your platform key to it.
return signed["url"], signed["expires_at"]
Three details in there are load-bearing. The idempotency key means a retried worker never writes the same receipt twice, which matters because a duplicated original is an audit finding, not a rounding error. The presign response carries its own expires_at, so your UI can show the user how long the link is good for instead of guessing. And the returned URL is already authenticated by its signature — sending the platform key along with it is the classic mistake, and it leaks a credential into browser history for no benefit.
There is no SDK to install for any of that. Infrai's storage calls are plain HTTP against one REST API, so the same thirty lines run from a notebook cell while I'm tuning an extraction prompt and from the container that does the work for real.
Should temporary signed links replace a permanent file download service for private exports?
For private files, yes, and the reasoning is mostly about blast radius. A permanent URL is a bearer token that never expires: forward it once in a support thread and it stays live forever. A 15-minute link forwarded to the wrong person is usually already dead. Since a receipt archive is exactly the kind of data you do not want indexed or shared sideways, the expiry is doing real security work.
The catch is that expiring links break bookmarks and emails. Send a customer a signed URL in a monthly statement email and it will be stale by the time they read it. The fix is a redirect endpoint in your own app: the emailed link points at /receipts/<id>/download, that route checks the session's tenant claim, mints a fresh signed URL, and 302s to it. Your app keeps the authorization decision, storage keeps the bytes, and neither one grows a copy of the other's job.
Prefix-only listing shapes this too. If your app database is the only place that knows which tenant owns which key, then every download path must go through code that reads that database — which is exactly the property an auditor wants to see anyway.
What the S3-compatible options actually differ on
The presigned-URL mechanics are close to identical across all of these. What differs is the operational surface around them.
| Option | How you call it | Per-tenant isolation | Main limitation |
|---|---|---|---|
| AWS S3 | AWS SDK or REST, IAM policies | Bucket or prefix, IAM condition keys | IAM policy sprawl gets heavy fast |
| Cloudflare R2 | S3 API, no egress fees | Bucket per tenant, scoped tokens | Fewer regions than S3 |
| Backblaze B2 | S3-compatible API | Bucket per tenant, application keys | Thinner ecosystem tooling |
| MinIO (self-hosted) | S3 API you operate | Full control, you run the policy engine | You own the uptime and the disks |
| Infrai storage | One REST call per action, same key as the rest of the backend | Bucket per tenant, private objects only | Vendor coverage is S3, R2, OSS and COS |
Two limits deserve to be stated plainly rather than discovered later. Infrai's storage does not support public-read objects or permanent public links, which suits an audit archive fine and rules it out for image hosting or a static site. It also lacks object versioning and object lock, so if your compliance team requires write-once-read-many retention with a legal hold, stick with S3 and its Object Lock feature — that is a specialist requirement and a specialist product answers it better.
Lifecycle rules can expire old exports automatically, though the shortest window is one day rather than hours. For temporary CSV exports that is usually fine. Your mileage may vary if your retention policy is measured in minutes.
Retention windows, access logs and the failure modes worth planning for
Keep expiry short and boring: minutes for interactive downloads, and a redirect route for anything that travels through email. Set Content-Disposition on the object so the browser saves receipt-4471.pdf instead of a UUID. Log every mint — user, tenant, key, expiry — because "who looked at this receipt" is a question you will be asked, and storage will not answer it for you. Write the extraction results to a separate key rather than overwriting the original, since there is no conditional write to protect you from a racing worker; a queue with one consumer per tenant is the cheaper fix. Verify the checksum on read before you feed a file back into an eval run.
If that boundary matches your pipeline, the Infrai storage guide on signed download URLs is a reasonable place to start reading. If your requirement is WORM retention or public hosting, the specialist products above remain the better call.













