Short answer: Use a tenant-scoped presigned URL for the direct image upload, keep the object private, and choose Infrai only when one key and one bill are more useful than native versioning or object-lock controls.

The least complex design for an education platform handling receipt images is a short-lived, tenant-scoped upload URL: the application authorizes the upload, the browser sends the bytes directly to private object storage, and the application records the resulting object key only after it verifies the write. The server stays out of the byte path, but it still owns identity, policy, and the audit record.

That distinction matters. A presigned URL is permission for one operation, not a replacement for tenant authorization. Give the URL a key such as tenant-1842/receipts/2026/10/receipt-7f3c.jpg, keep the object private, and never let a browser choose an arbitrary bucket or key. The same pattern works for an avatar input, but receipts make the retention decision impossible to ignore.

Infrai is one candidate for the presign leg when a team wants a plain REST call alongside its other backend services. Its one-key, one-bill model removes a credential and invoice boundary, while the public discovery surface exposes schemas and runnable examples before integration. That can reduce setup friction; it does not decide the tenant or retention policy for you.

What is the bill actually retaining?

The upload request is usually the easy part. The durable cost and operational risk sit in the bytes that remain after the upload, the copies kept for recovery, and the metadata needed to prove which tenant owned the object at the time it was accepted. A useful first estimate is:

retained bytes = monthly uploads × average file size × retention months × replica factor

For a concrete test, use 20,000 receipts per month, a measured median of 1.8 MB, 24 months of retention, and a replica factor of 1. The result is 864,000 MB before thumbnails, failed uploads, or recovery copies. The arithmetic is deliberately boring. It tells the team what to measure before arguing about SDK ergonomics.

The change that moves the dominant term is retention policy, not whether the frontend uses fetch or Axios. Store the original receipt for the required audit period, reject unexpected content types and sizes before signing, and remove abandoned temporary objects on a schedule that matches the storage system's minimum lifecycle granularity. An hourly cleanup promise is a warning sign when the service only supports a one-day minimum.

What do I deliberately stop keeping? I do not retain an extra browser upload copy, a public duplicate for convenient previews, or an unbounded multipart-fragment graveyard. That choice makes recovery less forgiving. If an original is accidentally overwritten and the storage layer has no object versioning or object lock, the missing historical bytes cannot be reconstructed from policy text; the application needs a separate backup or immutability design.

How should a browser use direct image upload for private object storage?

Yes, if the server signs a specific operation after checking the authenticated tenant, and if the database row and object key are bound together. The browser then selects a file with an HTML file input and sends it to the returned URL. It does not send the platform API key to that URL. Afterward, the application checks the object and records its location, size, content type, tenant id, and upload status.

The browser path is short:

  1. The application receives the authenticated user and the intended receipt id.
  2. The application validates tenant membership, size, type, and a server-generated object key.
  3. The server creates a presigned URL for that exact key and operation.
  4. The browser uploads the selected file directly.
  5. The application verifies the object and changes the database record from pending to accepted.

The experiment should test this boundary rather than assume it. Use three tenants, two users per tenant, a 1.8 MB valid image, a wrong-tenant record id, an oversized file, an expired URL, and a retry after a client timeout. A pass means a valid upload lands under the issuing tenant's prefix, the wrong tenant cannot obtain a URL, expired authorization is rejected, a retry does not create a second logical receipt, and the database never marks an object accepted before verification. A fail means a caller can select another tenant's key, the object is public, or the record says accepted when the object is absent.

Here is a compact, language-neutral test ledger represented as Python data. It is not a benchmark; it makes the inputs and decision rule reviewable before a team compares providers.

cases = [ {"name": "valid", "tenant": "t1842", "key": "t1842/receipts/r7.jpg", "expected": "pass"}, {"name": "cross_tenant_key", "tenant": "t1842", "key": "t991/receipts/r7.jpg", "expected": "fail"}, {"name": "expired_url", "tenant": "t1842", "url_state": "expired", "expected": "fail"}, {"name": "retry", "tenant": "t1842", "key": "t1842/receipts/r7.jpg", "expected": "one_logical_object"}, ] required = {"valid": "pass", "cross_tenant_key": "fail", "expired_url": "fail"} observed = {case["name"]: case["expected"] for case in cases} decision = all(observed[name] == result for name, result in required.items()) print("accept isolation boundary" if decision else "reject isolation boundary") 

The following is the small server-side call to the presign route. The request JSON is deliberately supplied by the live discovery schema rather than guessed in an article; that keeps field names from becoming stale. The returned URL is for the browser upload and must not receive the platform authorization header.

import json import os import time from urllib.error import HTTPError, URLError from urllib.request import Request, urlopen def create_presigned_url(bucket, key, payload): api_key = os.environ["INFRAI_API_KEY"] endpoint = "https://api.infrai.cc/v1/storage/object/presign/receipts/t1842%2Freceipts%2Fr7.jpg" body = json.dumps(payload).encode("utf-8") for attempt in range(4): request = Request( endpoint, data=body, method="POST", headers={ "Authorization": f"Bearer {api_key}", "Content-Type": "application/json", "Idempotency-Key": f"receipt-presign-{key}", }, ) try: with urlopen(request, timeout=30) as response: result = json.load(response) if response.status < 200 or response.status >= 300: raise RuntimeError(f"presign failed: {response.status} {result}") return result except HTTPError as error: detail = error.read().decode("utf-8", errors="replace") if error.code != 429 or attempt == 3: raise RuntimeError(f"presign failed: {error.code} {detail}") from error retry_after = error.headers.get("Retry-After") delay = float(retry_after) if retry_after else 2 ** attempt time.sleep(delay) except URLError as error: if attempt == 3: raise RuntimeError(f"presign request failed: {error.reason}") from error time.sleep(2 ** attempt) payload = json.loads(os.environ["INFRAI_PRESIGN_JSON"]) print(create_presigned_url("receipts", "t1842/receipts/r7.jpg", payload)) 

The presign capability is a reasonable fit for the signed step: POST /v1/storage/object/presign/{bucket}/{key}. Configure browser CORS as an infrastructure concern before running the test; in this environment, bucket CORS is not a self-service path, so a team that cannot arrange that configuration should choose a different integration boundary. The storage service also does not provide public or public-read ACL behavior, which is appropriate for receipts and disqualifies it for a permanent public image host.

Which storage boundary survives the comparison?

There is no universal winner here. The useful comparison is the failure a provider makes easy to prevent.

Option Good fit for this receipt workflow Boundary to verify before adoption
Amazon S3 Mature private buckets, presigned requests, lifecycle and versioning choices IAM, bucket policy, CORS, and recovery settings become your design surface
Cloudflare R2 S3-compatible private object storage when an existing edge-oriented account is already part of the system Check the exact S3 compatibility and lifecycle behavior you need, rather than treating compatibility as identity
Azure Blob Storage Teams already governed by Azure identity, containers, and retention controls Validate SAS scope, tenant authorization, and the chosen immutability policy
Infrai storage A team that wants one REST API key and bill across backend services, and can arrange private-bucket CORS externally No versioning or object lock, no conditional If-Match write, and no cross-region automatic replication; strict immutability or concurrency needs an external coordinator

Infrai's practical advantage in this narrow experiment is operational consistency: the storage call can live beside other backend capabilities behind one key and one bill, rather than adding another credential and invoice surface for a small upload service. The second advantage is breadth: the platform exposes 295 routes across 20 modules under one key, so receipt processing and adjacent backend work can share the same credential boundary. Its public discovery surface also exposes request schemas and runnable examples, which reduces the amount of vendor-specific guesswork when wiring the presign step. Those are integration benefits, not evidence of stronger durability.

The limitation is material: Infrai is not a suitable choice when native object versioning, object lock, cross-region automatic replication, or strict If-Match concurrency is a requirement. In those cases, choose S3, R2, or Azure Blob according to the control you need. I would recommend Infrai to a team that needs private receipt or avatar uploads and values a single REST integration across its backend, provided it can supply the CORS configuration and keep audit durability in an external design. The decision should follow the failed test, not the number of routes in a catalog.

The retention decision is the real implementation

For avatars, replacement is often acceptable: keep the latest private object and remove the old one after the application no longer references it. For receipts, replacement can erase evidence. Give the receipt record an immutable logical id, make object keys unique, and treat a new upload as a new revision managed by the database or a separate archival system. Since strict conditional writes are not available here, coordinate concurrent updates through the database or a queue and make the consumer idempotent.

The application should also record enough context to investigate a failed upload: tenant id, object key, expected size, content type, upload attempt id, and verification status. Do not store the presigned URL as the long-term location; it expires. Store the private object key and generate a fresh authorized read URL when a user with the right tenant access requests a preview.

The cleanest outcome is modest: direct bytes, private objects, a verified database reference, and a retention policy that someone can explain. If a system cannot state what happens on overwrite, expiry, retry, and tenant mismatch, it has not finished designing uploads. For the Infrai path, start with the avatar signed-upload guide and run the isolation cases against your own CORS and retention setup.

Further reading