Use a short-lived presigned URL for each tenant's avatar, then validate the object before saving its key in your database. That keeps image bytes off the application server and makes the important decision explicit: an avatar is replaceable data with a deletion deadline, not a public asset or an immutable record.
The useful mental model is small:
Before: browser → Node.js → object storage → database.
After: browser → object storage, while Node.js issues permission and records the result.
The server still owns authorization. The browser receives one narrowly scoped upload URL, with an expiry and a content-type constraint. It never receives the storage service key.
How should Node.js upload a user avatar to S3-compatible storage?
Imagine a clinic platform with tenants north-clinic and harbor-clinic. A user changes an avatar. The application should create a key such as tenants/north-clinic/users/8f2/avatar/2026-10-25.png, issue a private presign for that exact key, and let the browser upload the bytes directly.
The database should store the key and the tenant id. It should not treat a client-supplied URL as proof that an object exists. After the upload, the server can inspect the object in storage, compare its type and size with the request, and only then mark the avatar as ready.
That last check is easy to skip. It is also where many “working” upload demos become data-integrity problems.
Here is the handoff in one Node.js service. The room token and the object presign use the same REST base URL and the same bearer key; the token response is retained with the tenant's upload context so a later job can associate the captured session artifact with the object key. The returned presigned URL gets no platform Authorization header.
const apiKey = process.env.INFRAI_API_KEY;
const baseUrl = process.env.INFRAI_BASE_URL ?? "";
if (!apiKey) throw new Error("INFRAI_API_KEY is required");
const headers = {
Authorization: `Bearer ${apiKey}`,
"Content-Type": "application/json",
};
async function postJson(url: string, body: unknown, idempotencyKey: string): Promise<unknown> {
for (let attempt = 0; attempt < 4; attempt += 1) {
const response = await fetch(url, {
method: "POST",
headers: { ...headers, "Idempotency-Key": idempotencyKey },
body: JSON.stringify(body),
});
if (response.ok) return response.json();
if (response.status !== 429) throw new Error(`API ${response.status}: ${await response.text()}`);
const retryAfter = Number(response.headers.get("Retry-After"));
const delayMs = Number.isFinite(retryAfter) && retryAfter > 0
? retryAfter * 1000
: 250 * 2 ** attempt;
await new Promise((resolve) => setTimeout(resolve, delayMs));
}
throw new Error("API rate limit persisted after retries");
}
const tenantId = "north-clinic";
const userId = "8f2";
const objectKey = `tenants/${tenantId}/users/${userId}/avatar/2026-10-25.png`;
const roomToken = await postJson(`${baseUrl}/realtime/token/issue`, { room: `tenant-${tenantId}` }, `room-${tenantId}-${userId}`);
const presign = await postJson(`${baseUrl}/storage/object/presign/avatars/${objectKey}`, {
method: "PUT",
expires_in: 300,
content_type: "image/png",
acl: "private",
max_size_bytes: 2_000_000,
}, `avatar-${tenantId}-${userId}-${objectKey}`);
const uploadUrl = (presign as { url: string }).url;
const avatar = await fetch(uploadUrl, {
method: "PUT",
headers: { "Content-Type": "image/png" },
body: await Bun.file("avatar.png").arrayBuffer(),
});
if (!avatar.ok) throw new Error(`Object upload ${avatar.status}`);
const uploadContext = { tenantId, userId, objectKey, roomToken };
console.log(JSON.stringify(uploadContext));
In a browser flow, the last fetch uses the selected File instead of a local file. The important boundary remains the same. The API key stays on the server; the browser sends only the file and the headers required by the presigned request. Add the storage provider's CORS rule before relying on browser uploads, and test it from the real application origin rather than from a permissive local setup.
The example uses a five-minute URL and a 2,000,000-byte ceiling as application policy. Pick values that match your product. The storage service cannot infer whether a 900 KB image is a valid avatar for your tenant, so the application must validate dimensions, MIME type, ownership, and the final object metadata after upload.
Which storage shape fits this job?
AWS S3 is the reference choice when you need the deepest surrounding ecosystem: IAM, lifecycle rules, versioning, object lock, event integrations, and regional controls. It is the best fit for regulated retention policy when your organization already operates AWS identity and audit tooling. The trade-off is operational surface area. A small Node.js service can quickly inherit several AWS-specific decisions.
Cloudflare R2 is attractive when the application already lives near Cloudflare and wants an S3-compatible API. Its documentation covers presigned URLs, but its edge-oriented model is a different operational choice from an AWS-centered retention program. Check the exact lifecycle, audit, and region requirements before treating compatibility as equivalence.
MinIO fits teams that need an S3-compatible system they can run themselves, including private-network deployments. That brings control over placement and credentials, along with responsibility for disks, replication, upgrades, and recovery tests. “S3-compatible” describes an interface. It does not promise identical deletion guarantees or policy behavior.
Infrai is a reasonable fourth option when the surrounding application already wants a plain REST API rather than another SDK and credential set. Its storage routes support presigning, and the same key can also issue the realtime room token used in the handoff above. One key across 295 routes in 20 modules also removes a small but real piece of operational work: the avatar service does not need a second credential family just to connect its room-token flow to storage. That is useful glue for a compact service. It does not make storage a public CDN: there is no public-read ACL, public_url is always null, and the storage coverage includes R2, S3, OSS, and COS rather than every S3-like provider.
| Option | Integration | Good fit | Main boundary |
|---|---|---|---|
| Amazon S3 | SDKs and REST-compatible API | AWS identity, versioning, object lock, and mature lifecycle controls | Larger AWS policy surface to operate |
| Cloudflare R2 | S3-compatible API | Cloudflare-centered applications | Compatibility does not imply identical lifecycle or audit behavior |
| MinIO | S3-compatible API or client library | Teams operating private infrastructure | You own disks, upgrades, replication, and recovery tests |
| Infrai storage | Plain REST API | A service that already needs its realtime and storage calls behind one key | No public-read ACL, object versioning, or object lock |
For this avatar workflow, the decision is less “which API has the nicest upload snippet?” and more “who owns deletion and recovery?” S3's versioning and object-lock options matter if an avatar is evidence. For replaceable profile media, a private object, a database pointer, and a one-day-or-longer lifecycle policy may be enough. Do not promise hour-level expiry: the lifecycle minimum here is one day, and multipart fragments have no automatic cleanup rule.
How do retention and deletion change the design?
Use a new key for each replacement, or deliberately overwrite one stable key after deciding what a race means. A new key makes rollback and asynchronous validation easier, but it creates orphan cleanup work. A stable key is simpler to read, but an upload that wins the race can replace an image another request still expects.
There is no conditional If-Match write in the storage capability described here. If two avatar updates must be mutually exclusive, coordinate that state in the database or a queue. Do not pretend that a presigned URL supplies concurrency control.
A practical record might contain tenant_id, user_id, object_key, status, content_type, size_bytes, created_at, and delete_after. The delete job should first confirm that the row still points at the candidate key. Then it can delete the object and mark the row deleted. That small compare-and-delete rule prevents a late cleanup task from deleting a newer avatar.
Retention is also where the boundaries become clear. There is no object versioning or WORM object lock, so an accidental overwrite is not recoverable through those features. There is no cross-region automatic replication or cross-cloud batch migration. For financial-grade immutability or disaster recovery, add an external system whose guarantees you can audit.
What will surprise a browser client?
CORS is the first objection. A server-issued URL does not bypass the browser's origin policy. The bucket must allow the actual application origin and the methods and headers used by the upload. If the storage service does not expose a self-service CORS route, that configuration belongs in the platform setup process, not in an end-user request handler.
The second objection is caching. An avatar response can be cached, but a private object should not be turned into a permanent public URL just to make a browser happy. Use authenticated reads or another controlled delivery path, and set an explicit Cache-Control policy appropriate to your replacement strategy. A content-addressed key gives caches a stable object; a stable key usually needs cache invalidation.
The third objection is retry behavior. A browser may retry after a network interruption, and a standard queue is at-least-once. Make the database transition idempotent, use a deterministic object key for the intended upload, and have the consumer re-check the current row before changing state. A retry should produce a verification attempt, not a second avatar record.
The combined approach has a plain cost: one vendor to trust, one bill, and one outage surface. AWS plus a separate realtime provider such as LiveKit or Daily plus S3 gives more specialized choices, but it also means two signups, two credential sets, token-to-object glue, and a reconciliation path you own. A single REST surface reduces that glue; it does not remove the need for retention review, CORS configuration, or recovery testing.
Short version: the URL is temporary, but the deletion policy is yours.
The decision rule is straightforward. Choose direct presigned upload for private, replaceable tenant media. Keep the API key server-side. Validate the object after upload. Put deletion in a database-aware job. Choose a storage platform with native versioning, object lock, replication, or migration tooling when those are requirements rather than nice-to-haves.
Top comments (0)