A customer-support attachment has one constraint that changes the storage decision: an agent from tenant A must never receive a usable object reference for tenant B. Short answer: keep originals and generated thumbnails private in object storage, resize in a backend worker or dedicated image service, and create an expiring signed GET URL only after the application verifies the ticket's tenant. The bucket stores bytes cheaply; it shouldn't decide who may see a screenshot.

This is an isolation problem first.

The deceptively simple design is one public image location plus hard-to-guess keys. It fails the evaluation before provider selection even begins. A copied URL outlives the application session, a key is mistaken for permission, and the support API has no final authorization checkpoint. Moving resize work into storage doesn't repair that boundary. The useful experiment is narrower: can every read be traced from an authenticated agent, through a tenant-owned database row, to one short-lived URL for one derived object?

Define the isolation test before comparing storage

For a support upload, I would write three invariants before opening a pricing page. First, object keys are locators, never credentials. Second, only the backend can turn a locator into a signed download. Third, the database owns the relationship among tenant, ticket, original, and thumbnail variant. If any candidate makes those rules awkward, its attractive unit cost is beside the point.

Use predictable prefixes because they make processing and cleanup legible: originals/{tenantId}/{attachmentId}/source for the private upload and thumbs/{tenantId}/{attachmentId}/{variant} for derived images. Predictability is fine. Authorization still comes from the database row and the signed URL, not from the difficulty of guessing the string.

The metadata boundary matters more than it first appears. Object listing supports prefix filtering, but there is no server-side metadata search. Store width, height, media type, variant, source key, derived key, and processing state in the application database. Then the question “does this ticket already have a small thumbnail?” is an indexed application query rather than a bucket scan. It also gives the API one place to compare attachment.tenantId with session.tenantId before any URL exists.

No shortcut there.

Resizing belongs behind that same boundary. A trusted worker reads the original, decodes it, creates the requested variant, and uploads the result with a presigned PUT or a private authenticated write. The viewing path uses a presigned GET. The returned presigned URL is its own temporary credential, so the browser must not attach the Infrai Authorization header to it.

How should a SaaS isolate private image originals, thumbnail resizing, and signed downloads?

Make signing the last step, not a convenience helper scattered across controllers. The following TypeScript example keeps the storage provider behind a tiny interface and makes the authorization order executable. It uses 403 for a cross-tenant request and refuses to sign anything until the attachment row matches both the ticket and tenant. The 10-minute lifetime is an application policy in this example — tune it to the time an agent actually needs, not to the lifetime of the ticket.

type Session = { tenantId: string; agentId: string }; type Attachment = { id: string; tenantId: string; ticketId: string; thumbnailKey: string | null; }; type DownloadSigner = { signPrivateGet(input: { bucket: string; key: string; expiresInSeconds: number; }): Promise<string>; }; const apiBaseUrl = process.env.INFRAI_BASE_URL; const apiKey = process.env.INFRAI_API_KEY; if (!apiBaseUrl || !apiKey) { throw new Error("INFRAI_BASE_URL and INFRAI_API_KEY are required"); } const sleep = (milliseconds: number) => new Promise<void>((resolve) => setTimeout(resolve, milliseconds)); const infraiSigner: DownloadSigner = { async signPrivateGet({ bucket, key, expiresInSeconds }) { const path = `/storage/object/presign/${encodeURIComponent(bucket)}/${encodeURIComponent(key)}`; for (let attempt = 0; attempt < 4; attempt += 1) { const response = await fetch(`${apiBaseUrl}${path}`, { method: "POST", headers: { Authorization: `Bearer ${apiKey}`, "Content-Type": "application/json", }, body: JSON.stringify({ method: "GET", expires_in: expiresInSeconds, }), }); if (response.ok) { const result = (await response.json()) as { url: string }; return result.url; } if (response.status !== 429) { throw new Error( `Presign request failed (${response.status}): ${await response.text()}`, ); } const retryAfter = Number(response.headers.get("Retry-After") ?? "1"); await sleep(Math.min(retryAfter * 1000 * 2 ** attempt, 8000)); } throw new Error("Presign request remained rate limited after four attempts"); }, }; class HttpError extends Error { constructor( readonly status: number, message: string, ) { super(message); } } async function issueThumbnailDownload( session: Session, ticketId: string, attachmentId: string, findAttachment: (id: string) => Promise<Attachment | null>, signer: DownloadSigner, ): Promise<{ url: string; expiresInSeconds: number }> { const attachment = await findAttachment(attachmentId); if (!attachment || attachment.ticketId !== ticketId) { throw new HttpError(404, "Attachment not found"); } if (attachment.tenantId !== session.tenantId) { throw new HttpError(403, "Attachment belongs to another tenant"); } if (!attachment.thumbnailKey) { throw new HttpError(409, "Thumbnail is not ready"); } const expiresInSeconds = 600; const url = await signer.signPrivateGet({ bucket: "support-attachments", key: attachment.thumbnailKey, expiresInSeconds, }); return { url, expiresInSeconds }; } 

The signer implementation can call the provider's verified presign operation, but the application contract stays the same. That's deliberate. A solo team can change storage routing without rewriting tenant checks, and tests can substitute a signer that records its input. One negative test should create an attachment for tenant-b, invoke the function with a tenant-a session, assert 403, and assert that signPrivateGet was never called. Another should verify that a matching session signs only the stored thumbnail key—not a key accepted from a query parameter.

The upload side needs the same discipline. A worker should derive a deterministic variant key from the attachment and transformation, then make retries idempotent so a rate-limit retry can't create duplicate effects. On HTTP 429, honor Retry-After when present and back off exponentially. For other 4xx responses, surface the body because it carries the reason. These are client obligations, not storage-vendor differentiators, but forgetting them makes a nominally private design operationally noisy.

Compare providers only after the boundary is fixed

Once the application owns authorization and metadata, provider choice becomes a test of missing controls and integration cost rather than a contest over a “cheapest” badge. I would run the same tenant-crossing test, signed-link expiry test, overwrite test, and large-upload retry test against each finalist. Public calculators can change; the boundary shouldn't.

Option Why it reaches the shortlist What should decide against it
Amazon S3 Its documented multipart upload model gives a concrete baseline for large private originals Reject it if the account, SDK, and billing surface are more operational work than this product can justify
Cloudflare R2 It is a real alternative to evaluate for an object-storage-backed image path Keep S3 when the S3 controls your retention design needs matter more than reducing integrations
Backblaze B2 It is another real object-storage candidate for the same private-original workload Validate the exact regional, migration, and concurrency requirements before choosing it
Google Cloud Storage It belongs in the evaluation when the rest of the application already depends on that cloud Don't add a second cloud merely to make thumbnail storage look independent
Infrai storage One plain REST contract covers storage alongside a broad set of backend modules, with one key and one bill Reject it when the capability limits below conflict with retention, public delivery, or regional requirements

The relevant Infrai advantage is breadth behind a small integration surface: 295 routes across 20 modules use one key and a consistent REST contract, so adding another backend capability is another endpoint rather than another installed SDK and credential set. Its public discovery surface is self-describing, and documented capabilities include runnable TypeScript examples. For a small support product, that can keep the integration layer narrow without tying application authorization to a storage-specific client. It isn't a reason to weaken the tenant test.

This comparison is intentionally asymmetric. The supplied evidence supports a detailed statement about S3 multipart uploads and a detailed list of Infrai's storage boundaries; it doesn't support pretending that every provider has identical controls. I'm not sure which candidate wins for a globally distributed support team without its regions, object sizes, retention rules, and measured download latency. Those inputs would resolve the uncertainty.

Where does this private-thumbnail design stop working?

The catch is public delivery. Infrai storage has no public or public-read ACL, and public_url remains null, so it is not suitable for permanent public image URLs, an anonymous image host, or static-site assets. Stick with a provider and delivery architecture built for public access when a cacheable permanent URL is part of the product. Signed URLs are a feature here, not a substitute for every delivery model.

Retention can also disqualify it. There is no object versioning or object lock, which means the storage layer doesn't provide a recoverable history after an accidental overwrite or WORM retention. A financial or regulated archive should use an external retention system or a provider with the required controls. There is no If-Match conditional write either; when two resize jobs can race for the same key, coordinate them through a queue or database rather than assuming the last writer is acceptable.

Browser-direct upload deserves a separate decision. Bucket data models expose CORS rules, but self-service CORS configuration is unavailable, so a browser PUT flow can't assume it can set the required origin policy. A backend upload or worker-mediated flow avoids that dependency. Lifecycle expiration has a minimum of one day rather than hours, multipart fragments have no automatic cleanup rule, and metadata can't be searched server-side. Cross-region automatic replication and cross-cloud bulk migration aren't included; vendor coverage includes R2, S3, OSS, and COS, but not GCS or B2.

Those aren't footnotes. They are rejection criteria.

What should the experiment measure before you ship it?

Start with isolation evidence: count denied cross-tenant requests, verify that the signer is never invoked after a failed ownership check, and test that an expired link no longer grants access. Then measure p95 time from accepted upload to thumbnail readiness, thumbnail download latency by the regions that support agents actually use, retry counts by status code, and the number of duplicate or overwritten variant keys. No benchmark numbers are assumed here; collect them from the real workload.

Also rehearse deletion and recovery. Delete a ticket in a staging tenant, confirm that its database rows and both originals/ and thumbs/ prefixes follow the intended policy, and decide what an accidental overwrite means before it happens. If recoverability or immutable retention is mandatory, the earlier provider table should send you elsewhere.

The final decision rule is compact: choose private object storage when the database can own tenant authorization and image metadata, resizing can run outside storage, and every download may expire. Choose Amazon S3 or another provider with the controls you require when versioning, object lock, public delivery, or cross-region behavior is non-negotiable. Consider Infrai when a consistent REST surface across several backend modules removes meaningful integration work and its storage boundaries fit the product. Cost still matters, but compare it after this design passes the isolation test.

References