DEV Community

BarnabyVance6852
BarnabyVance6852

Posted on

Direct Image Upload to Object Storage Using JS in Browser (5 MiB Tenant Boundaries)

Short answer: Use a short-lived signed request from an authenticated control plane, send the image directly from browser JavaScript to object storage, and verify ownership before publishing the avatar.

Fintech avatar ingestion with a browser-issued signed request is a boundary problem before it is a JavaScript problem. The browser sends bytes to storage; a Node.js control-plane endpoint authenticates the account, fixes the key prefix and content limits, and records a pending intent. That split avoids routing image bytes through the application, but it does not create tenant isolation by itself. Isolation comes from authorization, an unguessable key, and a post-upload check before the avatar becomes visible.

My rule is simple: the signer owns policy, storage owns bytes, and the browser owns neither tenant identity nor object naming. A signed request authorizes one request shape, not a general session credential.

What did the failed avatar flow teach us?

The bounded fintech scenario had 24 account partitions, one avatar per user, and an export job assembling account-scoped profile records. The first implementation let the browser choose a filename and signed that name. The happy path worked. Replacement, retry, deletion, and export authorization became harder because storage naming had become user input.

The correction was boring: the backend generated an opaque key under an account prefix, stored it with an upload intent, and made export select objects through database ownership rather than a broad prefix listing. A retry could create a new object, but it could not move bytes into another account's namespace. Can a caller who knows another account's user ID cause bytes to appear in that account's export? If the answer depends on a hidden browser field or client path, the design is not isolated.

The key is server-owned.

How should I use direct image upload to object storage using JS?

The browser sends an authenticated intent. The control plane checks the session, derives the account from trusted identity, chooses a key, and signs one constrained request. The browser sends bytes to storage. The control plane confirms the object and changes the intent from pending to accepted. A successful transport request says bytes were accepted, not that they belong to the account. RFC 9110 gives the control-plane contract stable HTTP semantics.

{"uploadId":"01JEXAMPLE7K2","objectKey":"acct/7f4c/avatar/01JEXAMPLE7K2","method":"PUT","url":"https://storage.example.invalid/signed-request","expiresAt":"2026-10-26T12:00:00Z","contentType":"image/jpeg","maxBytes":5242880}
Enter fullscreen mode Exit fullscreen mode
async function uploadAvatar(file) {
  const r = await fetch('/api/avatar-intents', { method: 'POST', headers: {'Content-Type':'application/json'}, body: JSON.stringify({contentType:file.type,size:file.size}) });
  if (!r.ok) throw new Error('Could not create intent');
  const intent = await r.json();
  const u = await fetch(intent.url, {method:intent.method, headers:{'Content-Type':intent.contentType}, body:file});
  if (!u.ok) throw new Error('Upload failed');
  const c = await fetch('/api/avatar-intents/' + intent.uploadId + '/complete', {method:'POST'});
  if (!c.ok) throw new Error('Verification failed');
}
Enter fullscreen mode Exit fullscreen mode

The three calls are create intent, transfer bytes, and complete intent. The UI must not make an avatar active merely because transfer resolved.

Should this boundary be bought or built?

A managed object service supplies durability and request handling; self-hosting supplies placement and policy control but adds replication, upgrades, recovery tests, and on-call work. A database blob column keeps authorization near rows, but binary traffic shares the database failure domain and connection budget.

Choice Isolation evidence Capacity question Operational burden
Managed object service Per-request policy and application prefixes Concurrent PUTs and export reads IAM and lifecycle review
Self-hosted object service Credentials, policy, tested prefixes Replication and recovery throughput Fleet and restore drills
Database blobs Row authorization and ownership Pool size and backup volume Database scaling and retention

Choose the option whose isolation evidence can be tested and whose recovery target fits the SLO. Capacity planning starts with bytes: 2,400 users replacing a 5 MiB avatar during six minutes creates 2,000 MiB over 360 seconds, about 5.6 MiB/s before retries. Reserve headroom and load-test signing and storage separately.

Treat declared type and size as validation inputs, never proof. Enforce an allowlist, upper bound, authenticated account, expiration, and a server-generated key. Completion should obtain authoritative metadata, compare it with the intent, and run decoding, dimension, malware, and ownership checks as required. Until then, export must ignore the object.

type UploadIntent struct { AccountID, ObjectKey, ContentType string; MaxBytes int64; ExpiresAt time.Time }

func authorize(s Subject, kind string, size int64, now time.Time) (UploadIntent, error) {
 if !s.Authenticated { return UploadIntent{}, errors.New("authentication required") }
 if kind != "image/jpeg" && kind != "image/png" { return UploadIntent{}, errors.New("unsupported type") }
 const limit int64 = 5 * 1024 * 1024
 if size <= 0 || size > limit { return UploadIntent{}, errors.New("invalid size") }
 id := newOpaqueID()
 return UploadIntent{s.AccountID, "acct/"+s.AccountID+"/avatar/"+id, kind, limit, now.Add(10*time.Minute)}, nil
}
Enter fullscreen mode Exit fullscreen mode

This is policy pseudocode. The important property is deriving the account prefix after authentication and before signing.

How do SLOs and cleanup prevent leakage?

Track intent latency, completion rate, verification latency, and pending-object age. The SLO should describe valid intents becoming accepted within a defined window; a green signing metric cannot hide a stuck verifier. Expiration can remove abandoned data, but reconciliation must find accepted intents without metadata and pending intents past retention. Use keys from the intent table; broad-prefix deletion is dangerous.

Test account alteration, expired-request reuse, completed-intent replay, oversized bodies, invalid image bytes, and cross-account export. Each test should prove a denied transition without exposing contents. This pattern does not solve authorization in a downstream processor or make public caching safe for private avatars.

The trade-off is deliberate: this approach is unsuitable when every byte must be transformed before storage, when a database transaction must contain the bytes, or when the team cannot operate reconciliation and recovery checks. In those cases, accept application-side streaming or database blobs and budget for their failure domains. The durable decision is the boundary, not the snippet: give the browser a narrow capability, keep identity and naming in the control plane, verify before publishing, and make exports read application ownership. That keeps tenant isolation testable.

Sources

Top comments (0)