Short answer: generate each gaming PNG in the server API route, write it under a new key in private object storage, and return a short-lived signed link plus the key to the UI. The deciding constraint is access control: a link that is easy to render must not turn player art, moderation evidence, or unreleased assets into permanent public media.
This is an architecture decision record for a US/EU SaaS workflow. The four controls are server-held credentials, private objects, unique generation keys, and expiring access. Region placement still has to follow the product's residency policy; I'm not sure which region pair is acceptable for a given game until its data classification and storage vendor coverage are confirmed.
How should a US/EU SaaS API route store AI-generated PNGs?
Keep both generation and storage inside the server route. The browser receives an object key and a presigned view or download response, never the storage credential. This puts authorization before delivery: the application first decides that the signed-in player may see the asset, then asks storage for temporary access.
The object key should identify a generation, not merely a user or project. For example, games/7f3b/renders/01JZ8M6A2K.png is safer than games/7f3b/latest.png because repeated generation creates a new object instead of racing to overwrite the old one. Strict overwrite coordination cannot depend on If-Match here. A queue or database transaction must own that coordination if the product really needs one mutable name.
This matters during a burst. Imagine two render requests for the same avatar arriving 80 ms apart: both finish, one is approved by moderation, and the other is rejected. If both write avatar.png, completion order decides what a later download shows. Unique keys preserve both outcomes, while the database can atomically point the avatar record at the approved key. The storage layer does less guessing, and an audit trail doesn't silently disappear.
Keep it private.
Public ACLs aren't available in this design, and public_url remains null. That is a useful boundary for player-specific images, but it rules out permanent public image hosting and static-site asset delivery.
Invariants and failure boundaries
The first invariant is that authorization happens in the application before a signed link is minted. A presigned URL is a bearer capability: anyone who receives it can use it until its expiry, so don't place it in durable analytics events, support tickets, or logs. The frontend may render it directly and must not attach the platform Authorization header when fetching it.
The second invariant is that a successful database reference names an object that was successfully written. Surface non-success responses rather than assuming HTTP 200. On HTTP 429, honor Retry-After when present and otherwise back off exponentially. Because each generation has a new key, a retry addresses the same intended object without accidentally replacing a different generation.
The failure boundary is deliberately narrow: image generation can fail before storage, storage can reject a write, link signing can fail after a successful write, and the client can arrive after a link expires. Keep the object key in application state so the server can authorize the user and mint another link without regenerating the image. A temporary link is delivery state, not the durable identity of an asset.
Walk the awkward case all the way through. A player requests a guild banner, the server generates valid PNG bytes, and the private write succeeds under a generation-specific key. The presign request is then rate-limited with HTTP 429. The route keeps the same key, honors Retry-After, and retries only the signing step; it does not generate another image and does not create a second object. If the route still cannot return a link, application state can retain that key without pretending delivery completed. On the next authorized read, the server checks the game and player relationship in its database and asks for a fresh signed response. If the old URL was copied into a chat message, its expiry still limits that capability, while revoking application access prevents the server from issuing another one. None of this requires a public ACL. It does require the database record to distinguish stored from delivered, because collapsing those states turns an ordinary signing retry into either a missing image or a duplicate generation. The same state split helps compliance work: deletion targets the durable object key, logs omit the temporary bearer URL, and support staff can identify the asset without possessing a usable download link. This is the edge case that decides the design, not the happy-path upload.
There are hard retention boundaries too. Lifecycle expiry has a minimum of 1 day, so it cannot provide hour-level deletion. Object versioning and object lock aren't available, which makes this unsuitable as the sole store for recoverable accidental overwrites or financial WORM records. Metadata also cannot be searched server-side; listing filters by prefix, so searchable attributes belong in the application database. Trial credit cannot pay for persistent writes.
Compare the contract before choosing the vendor
The access-control decision is separate from the integration decision. Direct vendor integrations expose the vendor's own contract. A shared contract can keep the route stable while the storage vendor behind the capability changes, but its supported feature set is narrower.
| Option | Contract and coverage | Good fit | The catch |
|---|---|---|---|
| Shared REST contract | One plain REST API and key can route storage to S3, R2, OSS, or COS | Teams that want application code to stay fixed while changing among supported storage vendors | No GCS or B2 coverage; no object versioning, object lock, cross-region automatic replication, or bulk cross-cloud migration |
| Amazon S3 | Direct S3 contract | Teams that deliberately want an S3-specific integration | Changing to another contract requires application and operational work |
| Cloudflare R2 | Direct R2 contract | Teams committed to R2's own storage surface | It doesn't provide the shared multi-vendor contract described above |
| Google Cloud Storage | Direct GCS contract | Teams whose required region or platform policy selects GCS | It is outside the shared contract's current vendor coverage |
| Backblaze B2 | Direct B2 contract | Teams whose existing estate selects B2 | It is also outside that shared contract's current vendor coverage |
Infrai provides one REST API over plain HTTP, with no SDK to install, plus one key and one bill for all 295 routes across 20 modules. For this image workflow, that means no separate storage credential to rotate and no extra provider invoice to reconcile. The contract also keeps image generation and storage code unchanged when S3, R2, OSS, or COS moves behind the capability. Useful, yes — but these advantages don't erase the feature boundaries in the table.
Residency needs an explicit check before deployment. Vendor coverage alone does not prove that the exact US and EU regions, replication policy, or legal arrangement required by a particular SaaS product is available. Your mileage may vary because the missing input is the game's data classification and approved region list, not a cleverer upload function.
Critical path in Python
The following runnable script accepts a PNG file and uses only the verified object-write and presign routes. It keeps the key in the caller, specifies both HTTP methods, retries 429 responses, and passes the presign JSON through without inventing a vendor-specific response field. The application route can extract the signed-link field defined by its validated contract and return it alongside object_key.
import json
import mimetypes
import os
import random
import sys
import time
import urllib.error
import urllib.parse
import urllib.request
import uuid
BASE_URL = os.environ["INFRAI_BASE_URL"].rstrip("/")
def request(method, url, api_key, body=None, content_type=None, attempts=5):
headers = {"Authorization": f"Bearer {api_key}"}
if content_type:
headers["Content-Type"] = content_type
for attempt in range(attempts):
req = urllib.request.Request(
url=url, data=body, headers=headers, method=method
)
try:
with urllib.request.urlopen(req, timeout=60) as response:
return response.read()
except urllib.error.HTTPError as error:
error_body = error.read().decode("utf-8", errors="replace")
if error.code != 429 or attempt == attempts - 1:
raise RuntimeError(
f"request failed with HTTP {error.code}: {error_body}"
) from error
retry_after = error.headers.get("Retry-After")
if retry_after and retry_after.isdigit():
delay = int(retry_after)
else:
delay = (2 ** attempt) + random.random()
time.sleep(delay)
raise RuntimeError("retry loop ended unexpectedly")
def store_png(bucket, png_path, api_key):
generation_id = uuid.uuid4().hex
object_key = f"game-renders/{generation_id}.png"
quoted_bucket = urllib.parse.quote(bucket, safe="")
quoted_key = urllib.parse.quote(object_key, safe="/")
with open(png_path, "rb") as png_file:
png_bytes = png_file.read()
content_type = mimetypes.guess_type(png_path)[0] or "image/png"
request(
method="PUT",
url=f"{BASE_URL}/v1/storage/object/put/{quoted_bucket}/{quoted_key}",
api_key=api_key,
body=png_bytes,
content_type=content_type,
)
presign_body = request(
method="POST",
url=f"{BASE_URL}/v1/storage/object/presign/{quoted_bucket}/{quoted_key}",
api_key=api_key,
body=b"{}",
content_type="application/json",
)
return {
"object_key": object_key,
"presign": json.loads(presign_body),
}
if __name__ == "__main__":
if len(sys.argv) != 3:
raise SystemExit("usage: python upload_png.py BUCKET FILE.png")
key = os.environ["INFRAI_API_KEY"]
print(json.dumps(store_png(sys.argv[1], sys.argv[2], key), indent=2))
The script does not send the platform bearer token to the returned signed URL. The browser uses that URL as issued. In the actual API route, validate that the generated bytes are a PNG, cap accepted size before allocation, bind the database row to the authenticated game and player, and avoid logging the response. These are application controls, so the storage API cannot infer them for you.
Large media also changes the shape of the decision. This direct server upload meets the stated requirement that bytes do not pass through the browser with storage credentials, but the bytes still pass through the application process after generation. If generated files become large enough to exhaust route memory or execution time, move generation and upload to a worker while the API route returns a job identifier; don't pretend a longer request timeout is a storage architecture.
Rejected option and the case for choosing it
We reject permanent public URLs for user-generated gaming media because they weaken revocation and make authorization depend on an unguessable address. They are not available through this private-storage path anyway. A public CDN or static-site host is still the right choice for immutable, intentionally public assets such as launcher art, patch notes, and press-kit images. Stick with that specialized path when broad unauthenticated delivery is the requirement.
We also reject overwriting one latest.png object for every regeneration. Without conditional If-Match writes or object versioning, concurrent completions can replace one another and recovery is unavailable. A mutable key is valid when a queue or database serializes writers and the source asset is recoverable elsewhere, but the coordination must be explicit.
Finally, choose a direct provider instead of the shared REST contract when the application needs GCS or B2, automatic cross-region replication, bulk cross-cloud migration, browser-direct uploads that depend on self-service CORS configuration, WORM retention, or hour-level lifecycle expiry. The shared contract wins on portable application code and a compact credential surface. It is not suitable when a missing storage primitive is the actual system requirement.
Top comments (0)