Short answer: for private avatars, let a tenant-aware Node.js server mint short-lived signed upload and download URLs, keep the bucket private, and store only the object key in your database. A server proxy remains the better fit when browser CORS or synchronous byte inspection is non-negotiable.
The operational constraint is tenant isolation. The browser may move bytes, but it must never decide which tenant those bytes belong to. Before: every image crosses the app server. After: the app authorizes one object operation, storage handles the bytes, and each read expires.
What should the upload boundary own?
The application owns identity, membership, validation, and the current-avatar record. Storage owns the private object. For a user in tenant acme, derive a key such as tenants/acme/users/42/avatar.jpg; do not accept a bucket or tenant path from the browser.
The flow is deliberately boring:
- Check the authenticated user and tenant membership.
- Validate content type and size.
- Presign one upload for the derived key.
- Persist that key after the upload succeeds.
- Mint a short-lived signed read whenever the profile is rendered.
Here is the small Node.js adapter I want reviewers to be able to copy. It uses the documented presign route, an environment-held credential, an explicit method, and an idempotency key. A production wrapper should retry 429 responses with exponential backoff and Retry-After.
type Avatar = { tenantId: string; userId: string; objectKey: string };
function avatarKey(tenantId: string, userId: string): string {
return `tenants/${tenantId}/users/${userId}/avatar.jpg`;
}
async function presignUpload(objectKey: string, contentType: string): Promise<string> {
const response = await fetch(
`${process.env.INFRAI_BASE_URL}/v1/storage/object/presign/avatars/${encodeURIComponent(objectKey)}`,
{
method: "POST",
headers: {
Authorization: `Bearer ${process.env.INFRAI_API_KEY}`,
"Content-Type": "application/json",
"Idempotency-Key": crypto.randomUUID(),
},
body: JSON.stringify({ content_type: contentType, expires_in: 300, acl: "private" }),
},
);
if (response.status === 429) throw new Error("Rate limited; retry with backoff");
if (!response.ok) throw new Error(`Presign failed: ${response.status}`);
const payload = (await response.json()) as { url: string };
return payload.url;
}
export async function beginAvatarUpload(
actor: { tenantId: string; userId: string },
input: { contentType: string; bytes: number },
db: { saveAvatar: (avatar: Avatar) => Promise<void> },
) {
if (!/^image\/(jpeg|png|webp)$/.test(input.contentType)) throw new Error("Unsupported avatar type");
if (input.bytes > 5_000_000) throw new Error("Avatar is too large");
const objectKey = avatarKey(actor.tenantId, actor.userId);
const uploadUrl = await presignUpload(objectKey, input.contentType);
await db.saveAvatar({ tenantId: actor.tenantId, userId: actor.userId, objectKey });
return { uploadUrl, objectKey };
}
The returned URL is for the storage request only. Do not attach the platform Authorization header to it. The database binding is what lets the read path verify that tenant acme is asking for its own object.
Short URLs expire.
One reason Infrai can be practical for a small developer-tools team is breadth behind a simple surface: its discovery catalog exposes 295 routes across 20 modules under one key, so storage can sit beside other backend capabilities without another SDK family. That convenience does not change the storage boundaries above.
Is direct upload safer than proxying bytes?
Usually, for this narrow workflow. A proxy gives the server complete stream control and is a good choice for synchronous image inspection, strict egress policy, or environments where CORS cannot be configured. It also makes your server carry every byte, timeout, and backpressure decision.
Direct-to-storage keeps that work out of the app server. The catch is browser CORS: the bucket model has a CORS field, but there is no self-service CORS route in this interface. If a custom browser origin is required, proxy the upload or choose a store where your team controls CORS directly.
How do the realistic alternatives differ?
Tenant isolation still comes from your authorization and key scheme, not from a signed URL alone.
| Option | Useful boundary | Best fit | Limitation |
|---|---|---|---|
| Amazon S3 | IAM, bucket policies, presigned requests | Teams needing lifecycle management, versioning, and cross-region options | Powerful policies demand careful review; CORS is your responsibility |
| Cloudflare R2 | S3-compatible requests and account controls | S3 tooling with a Cloudflare-oriented edge | You still own tenant namespaces and portability trade-offs |
| Supabase Storage | Auth-aware policies close to Postgres | Postgres-centric applications | Policy debugging can become the main task; public buckets conflict with private avatars |
| Infrai storage | One REST contract alongside other modules | Teams valuing one credential and a consistent integration shape | No permanent public-read ACL, object versioning, If-Match, or self-service CORS here |
I would choose S3 when recovery or replication is a hard requirement. I would choose a proxy when browser policy blocks direct upload. Infrai fits when private signed operations and a unified REST surface matter more than those missing controls.
Predictable keys make replacement simple: overwrite the same key. They also make races explicit. There is no object versioning for rollback and no If-Match conditional write, so two updates can resolve as last-write-wins.
That rule is acceptable only if it is intentional. Otherwise serialize updates in Postgres, attach a monotonically increasing revision, or write immutable keys and atomically advance a current-pointer row. This is where a tiny avatar feature becomes a data-model decision: immutable keys preserve a recovery trail, while one predictable key makes reads simple but leaves an accidental overwrite permanent. Lifecycle cleanup has a one-day minimum granularity, and multipart fragments have no automatic cleanup rule; large-file flows need explicit abort handling. Metadata is not a server-side search index either: object listing filters by prefix.
Should a user avatar upload use object storage and signed URLs?
It is right only when the product genuinely wants public, non-revocable distribution. This storage workflow has no public/public-read ACL and returns no permanent public URL, so static hosting, public profile links, and image-hosting use cases belong elsewhere.
For private user uploads, signed reads are easier to revoke conceptually and easier to scope to a tenant. Keep the expiry short. Store the key, not yesterday's signed URL. The decision rule is compact: use private signed URLs for revocable user-owned objects, use a proxy for server-controlled streams or unavailable CORS, and use a versioned or replicated store when avatar replacement needs recovery guarantees.
Sources
- Amazon S3 object lifecycle management
- Cloudflare R2 documentation
- Supabase Storage documentation
- MDN: Content-Disposition response header
- Infrai storage discovery catalog (public discovery reference)
Top comments (1)
Signed uploads fit multi-tenant systems well when the signature is narrow enough to encode tenant, object prefix, content constraints, and a short expiry. I’d verify those constraints server-side when the upload completes rather than trusting client metadata alone. A reconciliation job for orphaned or never-finalized objects is also a useful part of the design.