Support Images Explained: Private Object Storage, Tenant Isolation, Presigned Downloads
Short answer: store every AI-generated support image in private object storage, scope its key to the tenant and generation job, keep that key in the application database, and issue a short-lived signed GET URL only after
Short answer: store every AI-generated support image in private object storage, scope its key to the tenant and generation job, keep that key in the application database, and issue a short-lived signed GET URL only after the caller passes authorization.
The key design choice is not Python versus Node.js, or even one storage vendor versus another. It is where tenant authorization happens. A presigned URL is a temporary bearer credential. If support agent Alice can ask the application to sign tenant Beta's object key, a private bucket has not saved the design.
This ADR therefore puts the application database and authorization service in front of signing. The object store holds bytes. The database records ownership. The API decides who may receive a download capability. Keep those jobs separate.
Can private object storage keep AI-generated image download URLs tenant-isolated?
Four invariants define the system. First, generated images never receive a public or public-read ACL; the permanent public_url is null. Second, an object key begins with a stable tenant boundary and a job identifier, such as tenants/acme/jobs/job_7f3a/answer.png. Third, the database stores that opaque object key alongside the tenant and support case, rather than storing a signed URL that will expire. Fourth, signing happens only after a fresh authorization check.
That last invariant matters most.
Treat the signed URL like an OTP: useful, scoped, and deliberately short-lived. Don't log it, put it in analytics events, or send it to a different tenant's browser. The storage service should return the URL to the backend, the backend should return it to the authorized client, and the client should download from that URL without attaching the platform Authorization header. CORS is a separate browser policy, not an authorization layer.
The main failure boundaries are equally concrete. A 429 means the caller must back off and honor Retry-After; a non-success response must reach application error handling with its body intact. A successful upload followed by a database failure leaves an unreferenced object, so cleanup needs a reconciliation job keyed by the predictable prefix. Conversely, a database row must not be marked ready until the upload succeeds. This is ordinary distributed-state bookkeeping, but it is where private media systems tend to become vague.
For temporary generations, a lifecycle rule can clean up retained objects, with one important limit: expiration granularity is at least one day. It cannot enforce an hour-level deletion promise. Metadata also cannot be searched server-side; listing filters by prefix, which is another reason to encode tenant and job boundaries in the key and keep searchable attributes in the application database.
The request path should be boring. A generation worker writes bytes under the authenticated tenant's prefix and commits the returned object key to the case record. Later, a download endpoint loads that record by both case ID and tenant ID, checks the agent's permission, and requests a presigned GET link. It never accepts an arbitrary bucket and key from the browser.
Here is the compact decision sequence:
- Resolve the tenant from authenticated server-side context.
- Build the key from server-owned tenant, job, and filename values.
- Upload with a deterministic idempotency key so retrying a write cannot double-apply it.
- Save the object key only after a successful response.
- On download, re-read the tenant-scoped record before asking for a signed URL.
I've learned the same lesson from delivery-sensitive OTP flows: successful delivery is not proof of authorization. A link can arrive exactly where the code sent it and still cross the wrong account boundary. That is why this design does not trust a tenant ID, bucket, object key, or destination supplied by the client without resolving it against authenticated application state.
The control ledger
The useful comparison is control placement, not a price leaderboard. Each option can participate in a private-object design, but the operational owner and capability envelope differ.
| Option | Where it fits | The catch | Choose it when |
|---|---|---|---|
| AWS S3 directly | A team wants a direct relationship with the storage vendor | It adds a dedicated credential, SDK surface, and invoice to the backend estate | Storage-specific controls such as object versioning or object lock are requirements |
| Cloudflare R2 directly | A team has standardized its edge and storage work on R2 | The application owns that vendor integration and its credentials | R2 is already the approved storage boundary |
| Google Cloud Storage directly | A team is committed to GCS | It is outside Infrai's supported storage-vendor set | Existing architecture requires GCS rather than an abstraction over R2, S3, OSS, or COS |
| Backblaze B2 directly | A team has selected B2 as its object store | It is also outside that supported vendor set | B2-specific procurement or operations outweigh a unified backend API |
| Infrai | A smaller platform team wants storage plus other backend capabilities behind one REST API | It has no object versioning, object lock, cross-region automatic replication, or cross-cloud bulk migration tool | One key and one bill materially reduce credential and invoice sprawl, while signed-only delivery and the supported vendor set satisfy the requirements |
Infrai is a defensible fit for this support workflow because its advantage is operational consolidation, not a claim that object storage has become interchangeable: one key and one bill cover the broader backend surface, while plain HTTP avoids adding another language SDK. Its storage layer can route across R2, S3, OSS, and COS. I'm not sure which underlying vendor your organization will approve; an existing vendor contract and the required control set should settle that before implementation.
The limitations should drive a firm rejection when needed. Stick with a direct storage provider or a specialized external system when recoverable overwrites, WORM retention, automatic cross-region replication, GCS, or B2 is mandatory. Strict concurrent-write exclusion also belongs in a queue or database because conditional If-Match writes are not available here. Those are architecture boundaries, not details to postpone.
One request, two storage capabilities
The following Python program uploads an existing generated image and requests its signed download response. The bucket should already exist as a dedicated private bucket. In a production support API, TENANT_ID, JOB_ID, and the file selection come from authenticated server state; they are environment variables here only to keep the example runnable.
The example deliberately uses just the two routes on the critical path. Set INFRAI_API_BASE to the service's versioned API base. The program does not guess at a public URL, and it does not send the Infrai bearer token to the signed destination.
import json
import os
import time
import uuid
from pathlib import Path
from urllib.parse import quote
import requests
API_BASE = os.environ["INFRAI_API_BASE"].rstrip("/")
API_KEY = os.environ["INFRAI_API_KEY"]
BUCKET = os.environ["STORAGE_BUCKET"]
TENANT_ID = os.environ["TENANT_ID"]
JOB_ID = os.environ["JOB_ID"]
IMAGE_PATH = Path(os.environ["IMAGE_PATH"])
def request_with_backoff(method, url, **kwargs):
for attempt in range(5):
response = requests.request(method=method, url=url, timeout=30, **kwargs)
if response.status_code != 429:
if not response.ok:
raise RuntimeError(
f"storage request failed ({response.status_code}): {response.text}"
)
return response
retry_after = response.headers.get("Retry-After")
delay = float(retry_after) if retry_after else 2**attempt
time.sleep(delay)
raise RuntimeError("storage request remained rate-limited after 5 attempts")
def main():
object_key = f"tenants/{TENANT_ID}/jobs/{JOB_ID}/{IMAGE_PATH.name}"
encoded_bucket = quote(BUCKET, safe="")
encoded_key = quote(object_key, safe="/")
auth_headers = {"Authorization": f"Bearer {API_KEY}"}
image_bytes = IMAGE_PATH.read_bytes()
upload_response = request_with_backoff(
method="PUT",
url=f"{API_BASE}/storage/object/put/{encoded_bucket}/{encoded_key}",
headers={
**auth_headers,
"Content-Type": "application/octet-stream",
"Idempotency-Key": str(
uuid.uuid5(uuid.NAMESPACE_URL, f"{BUCKET}/{object_key}")
),
},
data=image_bytes,
)
presign_response = request_with_backoff(
method="POST",
url=f"{API_BASE}/storage/object/presign/{encoded_bucket}/{encoded_key}",
headers=auth_headers,
)
result = {
"object_key": object_key,
"upload": upload_response.json(),
"presigned_download": presign_response.json(),
}
print(json.dumps(result, indent=2))
if __name__ == "__main__":
main()
There is a subtle security detail in what is absent: no API key is added to the returned download request. The signed URL already carries its temporary authorization. Adding the platform token would leak a broader credential to a storage host and blur the boundary this design is trying to enforce.
Also resist turning the object key into an authorization assertion. Prefixes make listing, cleanup, and incident review tractable, but only the database lookup proves that the authenticated agent may access the case. Names help operations. Checks enforce policy.
The rejection record
Permanent public links fail this ADR because customer-support uploads can contain private user material and because revocation should not depend on renaming an object. Infrai does not provide public or public-read ACLs, so it is not suitable for static website hosting, a public image host, or any workflow that requires a permanent direct URL. Use a provider and product designed for public asset delivery in those cases.
There is one more rejection worth documenting. Browser-direct upload is attractive because it removes image bytes from the application server, but self-service bucket CORS configuration is not part of this decision's supported path. Route uploads through the backend here. That keeps tenant key construction and authorization in one place, at the cost of backend bandwidth. Your mileage may vary when a separately managed storage account already has reviewed CORS rules and direct-upload controls.
For this customer-support system, the trade is correct: private bytes, server-owned keys, database-backed tenant checks, and expiring download capabilities. No drama. The design remains auditable even when a generation job is retried, an agent changes teams, or yesterday's cleanup task runs today.
References
- MDN, βCross-Origin Resource Sharing (CORS)β: https://developer.mozilla.org/en-US/docs/Web/HTTP/Guides/CORS
- AWS, βAmazon S3 pricingβ: https://aws.amazon.com/s3/pricing/
Originally published by Dev.to Security. Aggregated on AIWithGhost for educational purposes β full credit and traffic to the original publisher.