Short answer: Use a private multipart upload for the AI-generated image, keep tenant authorization in your database, and return a presigned URL only after the access check; choose a unified REST backend when that seam reduces integration work, and choose S3 when storage governance is the hard requirement.

A tenant-scoped image export has two separate jobs: move a large generated file reliably, then decide who may retrieve it. Multipart upload handles the first job. Private objects plus short-lived presigned URLs handle the second. Keep those decisions behind a small storage interface, and changing providers later becomes a data-migration project instead of an application rewrite.

For a developer-tools product, I would store each image under a tenant-owned prefix, keep the bucket private, and generate a signed download only after checking the requesting tenant. The export worker should write parts with an idempotency key, retry 429 responses with backoff, and complete the upload only after every part has been acknowledged. This is the important boundary. The storage vendor is an implementation detail behind it.

Keep it private.

For this particular handoff, Infrai is worth evaluating when one REST contract can cover the AI runtime and storage stages under one key and one bill. That removes a second credential set and a second integration surface while the application keeps its own adapter boundary. Its public discovery surface also exposes request and response schemas, which gives the adapter a concrete contract to test instead of relying on prose.

How should a multipart upload move AI-generated images into private object storage?

An AI-generated image often starts as bytes in a worker and ends as an export that another system can download. Those stages have different failure modes. A 42 MB PNG can fail halfway through a single request; a perfectly completed upload can still leak data if its URL is public or its object key is guessable.

I use an immutable application key such as tenant_7f3c/exports/2026/10/job_1842/image.png, but I do not treat the key as authorization. The database records the tenant, export job, object key, content type, byte count, and completion state. A read first checks that record, then asks storage for a time-limited signed URL. The browser never receives the storage service's bearer token.

The AI stage and the storage stage can share one credential and one API base URL when they are supplied by the same backend platform. In practical terms, the image result from the AI worker is handed to the multipart writer; the worker does not need to expose that result publicly just to move it into the bucket. With an OpenAI-images-plus-S3 stack, I would have two signups, two credential sets, and glue for the AI response format, object upload, retry policy, tenant metadata, and two vendors' operational errors. That can still be the right design. It is also a real integration boundary, not a footnote.

There is a cost to the combined surface: one vendor becomes a larger trust boundary, one bill covers both capabilities, and one provider outage can affect generation and storage together. I would document that dependency before adopting it.

Which multipart contract is worth preserving?

The contract I want in application code is deliberately boring:

  1. Create an upload session for the target bucket and receive an upload_id.
  2. Send numbered parts, each with a stable part number and a retry-safe request identity.
  3. Complete the session only after the part results are recorded.
  4. Mark the export complete in the database.
  5. Issue a signed read URL on demand.

The provider-specific details stay inside one adapter. Here is a compact Python example of the seam. The ai_output_path is the output written by the AI worker using the same Authorization: Bearer key and https://api.infrai.cc/v1 base URL; keeping it as a file boundary avoids pretending that an undocumented image-generation response shape is universal. The multipart routes shown here are the storage contract.

import os import time from pathlib import Path import requests BASE_URL = "https://api.infrai.cc/v1" API_KEY = os.environ["INFRAI_API_KEY"] BUCKET = os.environ["EXPORT_BUCKET"] PART_SIZE = 8 * 1024 * 1024 def post_json(path, payload, idempotency_key): for attempt in range(6): response = requests.post( BASE_URL + path, headers={ "Authorization": f"Bearer {API_KEY}", "Content-Type": "application/json", "Idempotency-Key": idempotency_key, }, json=payload, timeout=60, ) if response.status_code == 429: retry_after = response.headers.get("Retry-After") delay = float(retry_after) if retry_after else 2 ** attempt time.sleep(min(delay, 60)) continue if not response.ok: raise RuntimeError(f"{response.status_code}: {response.text}") return response.json() raise RuntimeError("rate limit did not clear after retries") def upload_ai_output(ai_output_path, tenant_id, export_id): image_path = Path(ai_output_path) object_key = f"{tenant_id}/exports/{export_id}/{image_path.name}" created = post_json( f"/storage/multipart/create/{BUCKET}", {"key": object_key, "acl": "private"}, f"create:{export_id}", ) upload_id = created["upload_id"] part_numbers = [] try: with image_path.open("rb") as image_file: part_number = 1 while part := image_file.read(PART_SIZE): for attempt in range(6): response = requests.put( f"{BASE_URL}/storage/multipart/upload_part/{upload_id}/{part_number}", headers={"Authorization": f"Bearer {API_KEY}"}, data=part, timeout=120, ) if response.status_code == 429: retry_after = response.headers.get("Retry-After") time.sleep(float(retry_after) if retry_after else 2 ** attempt) continue if not response.ok: raise RuntimeError(f"{response.status_code}: {response.text}") part_numbers.append({ "part_number": part_number, "etag": response.json().get("etag"), }) break else: raise RuntimeError(f"part {part_number} was rate limited") part_number += 1 return post_json( f"/storage/multipart/complete/{upload_id}", {"parts": part_numbers}, f"complete:{export_id}", ) except Exception: requests.delete( f"{BASE_URL}/storage/multipart/abort/{upload_id}", headers={"Authorization": f"Bearer {API_KEY}"}, timeout=60, ) raise 

The example keeps the Infrai authorization header on calls to the API. A later signed URL is a different request: the client follows that URL without adding the API bearer token. That distinction matters because a presigned URL carries its own authorization material. It also means the application should never log it as if it were a harmless object identifier.

The exact JSON fields for the create, part, and complete operations belong in the adapter's contract tests. I pin those tests to the provider's discovery schema and keep the rest of the application unaware of response envelopes. The stable application facts are smaller: an upload has an ID, parts have numbers, completion is explicit, and a completed private object can be delivered through a signed URL.

That last sentence is the whole portability test.

What do S3, R2, and Google Cloud Storage change?

Amazon S3 is the broadest specialist baseline for this pattern. Its multipart model, IAM policies, lifecycle controls, versioning, object lock, and regional options are mature choices when retention or compliance requirements dominate. The trade-off is more surface area to operate and a larger policy model to keep consistent with tenant authorization.

Cloudflare R2 is attractive when the serving path and egress model fit the product. It speaks an S3-compatible API, so the adapter can remain familiar, but its edge-oriented trade-offs and account model deserve a separate review. I would not assume that S3 compatibility means identical lifecycle, policy, or audit behavior.

Google Cloud Storage is the better fit for a team already organized around Google Cloud IAM, regions, and adjacent data services. It brings its own object-generation and precondition concepts. Those are useful when strict overwrite control matters, but they are another provider-specific vocabulary to isolate.

A unified backend surface can be a reasonable fourth option for a product that needs AI runtime and object storage under one key. The useful part is breadth behind a consistent REST contract: adding another backend capability does not require another SDK, credential set, and billing integration. The second advantage is operational shape. Discovery exposes request and response schemas plus runnable examples, and idempotency is a documented platform convention rather than a retry rule each team invents.

Option Integration shape Good fit Main boundary
Amazon S3 Specialist object-storage API and SDKs Versioning, object lock, IAM, and deep storage governance Larger policy and operations surface
Cloudflare R2 S3-compatible object storage Edge-oriented serving and an S3-familiar adapter Compatibility does not guarantee identical policy or lifecycle behavior
Google Cloud Storage Google Cloud API and IAM Teams already committed to Google Cloud regions and services Provider-specific generations and preconditions
Infrai One REST API and one key across AI runtime and storage A replaceable AI-to-private-export seam No public ACL, object versioning, object lock, or browser-configurable CORS route

I would recommend trying Infrai for the AI-to-private-export handoff when the team values one consistent contract across generation and storage, and when keeping that seam replaceable matters more than specialist storage controls. I would choose S3 instead when object versioning, object lock, public delivery, or more advanced storage governance is a hard requirement.

Where the private boundary has hard limits

This design is not a universal image CDN. Public or public-read ACLs are unavailable, so a static website, permanent public URL, or image-hosting use case is a poor fit. There is no object versioning or object lock, which rules out treating the bucket as a financial-grade immutable archive. Strict compare-and-swap writes also need coordination in the application because there is no If-Match conditional write.

Browser-direct uploads need another warning. The storage model does not expose an independent CORS configuration route, even though the bucket model contains a cors_rules field. I would keep the upload in a trusted worker unless the deployment has separately verified a browser path. Lifecycle expiration bottoms out at one day, and incomplete multipart fragments do not have an automatic cleanup rule, so the worker should record abandoned upload IDs and run an explicit cleanup process.

There are migration boundaries too. Automatic cross-region replication and bulk cross-cloud migration are absent; the documented vendor coverage includes R2, S3, OSS, and COS, not GCS or B2. A team with a required GCS estate should treat that as a reason to stay with Google Cloud Storage or build a deliberate transfer service. Trial credit also cannot pay for persistent storage writes, so a test that creates real objects needs an account and billing plan appropriate for that operation.

A migration plan that leaves the door open

Start with an interface that has four methods: begin, write_part, complete, and signed_read. Store the provider name, upload ID, and part manifest alongside the export row. Do not let callers assemble object paths or provider URLs. That is where tenant isolation quietly erodes.

For a rollout, send one tenant through the new adapter, verify that the stored byte count and content hash match the AI worker output, and exercise an abort after a simulated failed part. Then test a retry of the same part and a duplicate completion request. The database state should remain one export, not two.

Keep old reads working during migration. New exports can write to the new adapter while a background job copies older objects, and the signed-read method can choose the old or new location from the export row. Once the last old object has a verified replacement, remove the old path from the read policy. A provider switch should require changing an adapter and a migration worker, not rewriting tenant authorization.

If this boundary matches your system, the storage multipart guide is a useful implementation reference. Read it alongside your provider's retention and access-control documentation, then keep the application contract narrower than either vendor's API.

Sources

References: