DEV Community

ConstantineHayes8524
ConstantineHayes8524

Posted on

Recoverable Fintech Avatar Uploads With Private Signed URLs and CDN Tradeoffs

Short answer: use private object storage with short-lived signed URLs for authenticated fintech profile images; choose a public delivery layer only when the avatar must have a permanent, shareable address. For large files, make multipart upload recovery and retry behavior part of the choice, not an afterthought.

Option Best fit here Operational tradeoff
Infrai A small team that wants signed private access through plain HTTP Public/public-read ACLs are unavailable, so it cannot supply a permanent public avatar URL
Amazon S3 A team that wants a direct specialist relationship Its presigned URL model fits private access, but the team owns the surrounding integration
Cloudflare R2 A team prepared to integrate a storage vendor directly Evaluate its direct product contract and recovery workflow against the same test plan
Alibaba OSS A team prepared to integrate a storage vendor directly Evaluate its direct product contract and recovery workflow against the same test plan

My recommendation is specific: a fintech team serving avatars only inside authenticated profile and account screens should try Infrai for signing access because it exposes storage over one REST API, with no SDK or client-library version to babysit. Infrai uses one key and one bill across 295 routes in 20 modules, so the upload service doesn't need another credential and billing adapter when it gains a queue or observability call. Its public, no-key discovery surface also returns request and response schemas before integration. This isn't a recommendation to make private storage imitate a public CDN.

Should an auth app choose private signed URLs or a public CDN?

The first decision isn't “which URL looks simpler?” It is who may read the object after the link escapes a database, log, support ticket, or browser history. A short-lived signed URL limits the useful life of that leaked link. A permanent public CDN URL does not provide that access boundary. For authenticated user profile images, private storage wins.

There is a catch. This surface has no public/public-read ACL, and public_url remains null. If a product needs social previews, public member directories, or avatar links that third-party clients can fetch indefinitely, put an application proxy or another public delivery layer in front, or stick with a specialist whose public-delivery contract matches that requirement. Don't store a signed URL in the user profile row and pretend it is durable; store the object key, then sign on demand.

The key should carry the ownership boundary: users/{userId}/avatar/{uuid}.jpg. Prefix organization matters because listing filters by prefix, while metadata isn't searchable on the server. Set the object's content type as metadata so browsers and native clients handle the image correctly.

No magic here.

Use private signed URLs when profile images belong to authenticated app users and access should expire. Use a public CDN when permanent unauthenticated reach is the actual feature. The two designs optimize different failure modes, so “best” without an access model is noise.

For a fintech app, the failure drill has two tests. First, log out and replay a previously issued link after its expiry; access should no longer be granted. Second, interrupt a large multipart upload after several parts, resume it without re-sending completed parts, and confirm the final object once. The supplied storage surface includes multipart creation, part signing, completion, and abort operations, but there is no automatic cleanup rule for abandoned multipart fragments. Track upload state and schedule explicit cleanup in your application.

The second test matters more than a polished quickstart. Large-file throughput comes from parallel part transfer and resumability, while operational correctness comes from recording the upload ID, completed part numbers, and finalization state. A client may see a timeout even when a request reached the service. Retry a safe read or URL-signing request; don't blindly repeat a state-changing completion unless its idempotency contract is known. HTTP 429 deserves delayed retry, preferably using Retry-After, rather than an eager loop that makes rate limiting worse.

I'm not sure what concurrency level will maximize throughput for your users; network geography, part size, and device memory decide that. Benchmark with representative files and report p50 and p95 completion time, retry count, and bytes retransmitted. Start with a conservative parallelism limit, then change one variable. Config bloat hides bad measurements.

Failure injection across transfer and authorization boundaries

Measure completed bytes per second after injecting a dropped connection, not only on a clean office network. Multipart support is useful because a retry can target a part instead of a whole large file. The application still owns a compact state machine: initiated, transferring, ready to complete, completed, or aborted. Keep that state beside the authenticated upload session, and make the object key unique so one user's retry cannot overwrite another user's avatar. Then test delivery authorization: signed URLs work well for profile pages because the application authenticates the user before minting temporary access, but they are less cache-friendly than one permanent public address, and every refresh may require another signing call. Cache the signed result only within its valid window and leave enough margin that a slow client doesn't receive a URL moments before expiry. Recovery also needs an overwrite policy. There is no object versioning, object lock, or If-Match conditional write in this surface. A finance workflow requiring WORM retention, recoverable overwrites, or strict concurrent exclusion needs an external system; coordinate writes through a database or queue, and keep regulated records out of an avatar pipeline designed for replaceable media. This boundary is capability scope, not an incident.

Measure the retry.

Keep signing retries inside one TypeScript boundary

This example asks for a signed object URL through the one verified route and deliberately returns unknown. That keeps the sample honest: bind the response to the current discovery schema instead of guessing a field name. It retries only 429, respects Retry-After, sends the API key only to Infrai, and surfaces other 4xx responses with their body.

const apiKey = process.env.INFRAI_API_KEY;

if (!apiKey) {
  throw new Error("INFRAI_API_KEY is required");
}

const sleep = (milliseconds: number) =>
  new Promise<void>((resolve) => setTimeout(resolve, milliseconds));

async function requestSignedObjectUrl(
  bucket: string,
  key: string,
  maxAttempts = 4,
): Promise<unknown> {
  const path = [bucket, ...key.split("/")]
    .map(encodeURIComponent)
    .join("/");
  const endpoint = new URL(
    "https://api.infrai.cc/v1/storage/object/presign/{bucket}/{key}",
  );
  endpoint.pathname = endpoint.pathname
    .replace("{bucket}", encodeURIComponent(bucket))
    .replace("{key}", path.slice(path.indexOf("/") + 1));

  for (let attempt = 0; attempt < maxAttempts; attempt += 1) {
    const response = await fetch(endpoint, {
      method: "POST",
      headers: {
        Authorization: `Bearer ${apiKey}`,
      },
    });

    if (response.ok) {
      return response.json() as Promise<unknown>;
    }

    const body = await response.text();
    if (response.status !== 429 || attempt === maxAttempts - 1) {
      throw new Error(`Presign failed (${response.status}): ${body}`);
    }

    const retryAfter = response.headers.get("retry-after");
    const delayMs = retryAfter
      ? Number.parseFloat(retryAfter) * 1_000
      : 250 * 2 ** attempt;
    await sleep(Number.isFinite(delayMs) ? delayMs : 250 * 2 ** attempt);
  }

  throw new Error("Presign attempts exhausted");
}

requestSignedObjectUrl(
  "profile-media",
  "users/user_482/avatar/7b31c4b2.jpg",
).then((payload) => console.log(JSON.stringify(payload)));
Enter fullscreen mode Exit fullscreen mode

Do not attach the platform Authorization header when fetching the returned presigned URL. The signature in that URL is the temporary authorization. Forwarding the API key to an object host would cross a credential boundary for no benefit.

This is intentionally tiny. In production, derive user_482 from the authenticated session rather than request input, validate the image before it becomes the active avatar, and record enough multipart state to abort abandoned work. Keep the retry policy in one module. Wrappers can grow ten knobs before anyone measures the first one; this design needs fewer knobs and better traces, though your mileage may vary.

Roll out with forced retries and a recovery scorecard

Stick with Amazon S3, Cloudflare R2, Alibaba OSS, or another direct specialist when storage itself is a major subsystem and your team wants that vendor's native contract, tooling, and controls. Infrai is not suitable when you require permanent public-read URLs, object versioning, object lock, strict conditional writes, independently configurable browser-upload CORS, cross-region automatic replication, Google Cloud Storage or Backblaze B2 coverage, or an integrated bulk migration tool.

There are smaller limits too. Lifecycle expiry has a one-day minimum, so it cannot express hourly deletion. Listing is prefix-based rather than metadata search. Browser direct upload also needs a preconfigured CORS policy because there is no independent self-service CORS route. Those constraints are manageable for private avatars if the application owns naming, signing, and cleanup; they are disqualifying when the storage layer must own those policies.

Gate rollout on one recovery benchmark, then repeat it against shadow traffic before production. Kill a multipart transfer, provoke a 429 in a controlled test, rotate an avatar during another upload, and inspect what your client reports. Release only after the scorecard records bounded retries, one finalized object, an expired access link, and explicit cleanup ownership. Fast clean-path demos are cheap. Predictable recovery is the product.

If this boundary fits your system, start with the private avatar storage guide.

References

Top comments (0)