Nodejs Upload User Avatar to S3-Compatible Storage with Presigned URL Retention
Three Retention Gates for Private Health Avatar Intake with Expiring Links A health product should treat an avatar as private health-adjacent data. The practical design is a short-lived presigned upload URL, a private
Three Retention Gates for Private Health Avatar Intake with Expiring Links
A health product should treat an avatar as private health-adjacent data. The practical design is a short-lived presigned upload URL, a private object key, and a second server-side check before the database points at the object. The browser sends bytes directly to S3-compatible storage, so the application server does not become an image relay.
Short answer: issue a URL for one generated key, with a short expiry and an expected content type; let the browser upload with PUT; then inspect the stored object before saving the key to the user record. Give the object a deletion deadline. Mint a separate signed download link only when the user needs the image.
That sequence matters more than the storage brand. In a one-person SaaS, every minute spent operating upload plumbing competes with a feature that might increase revenue per hour. I want the smallest boundary that is easy to audit and hard to misuse. Ship weekly. Outsource byte transport.
Can Nodejs upload a user avatar to S3-compatible storage without relaying bytes?
The first gate is authorization. The API creates the object key, not the browser. A key such as avatars/user-1842/3b2...jpg lets the server enforce ownership and prevents a client from choosing another user's path. The key is an identifier, not a public URL.
The second gate is the signed request. A presigned URL grants one narrow operation for a limited time. The application still decides who may request it, which bucket is used, how long it lasts, and which content type is acceptable.
The third gate is confirmation. A successful browser request only tells the browser that the HTTP exchange completed. The server should inspect the object after upload and compare its stored size and type with its own limits before writing the key into the user record. If that check fails, delete the object and leave the record unchanged.
I initially treated a 200 response from the upload as the finish line. It is not. The database write is the finish line because that is what makes the object part of the product's data model.
How do the three retention gates fit together?
Think in states: pending means a URL exists but no trusted object exists; verified means storage inspection passed and the user record stores the key; expired means an unverified object passed its grace period or a verified object reached the product's retention deadline.
A cleanup worker can remove pending objects. A separate product policy can remove verified avatars after account deletion or another documented event. Keep those policies in the database as dates. Storage lifecycle rules are a useful backstop, but they are a poor replacement for application meaning.
A 10-minute signed upload URL is reasonable for an interactive profile form. It is not a retention policy. A one-day minimum lifecycle rule is also not the same thing as βdelete this user's image tomorrow at 09:00.β The application owns the deadline.
Smallest working Node.js implementation
The server creates a private object and signs the upload request. For a unified REST surface, use the verified presign route directly. Read its public discovery schema before adding request fields; this keeps the example from inventing undocumented parameters.
const apiKey = process.env.INFRAI_API_KEY;
const baseUrl = process.env.INFRAI_BASE_URL;
const bucket = process.env.AVATAR_BUCKET;
if (!apiKey || !baseUrl || !bucket) throw new Error("Required environment variables are missing");
export async function requestAvatarPresign(userId: string, objectKey: string) {
if (!objectKey.startsWith(`avatars/${userId}/`)) throw new Error("Avatar key is outside the user scope");
const presignPath = ["/v1", "storage", "object", "presign", encodeURIComponent(bucket), encodeURIComponent(objectKey)].join("/");
const response = await fetch(`${baseUrl}${presignPath}`, {
method: "POST",
headers: { Authorization: `Bearer ${apiKey}`, Accept: "application/json" },
});
if (response.status === 429) throw new Error("Presign rate limited; retry after the server interval");
if (!response.ok) throw new Error(`Presign failed with ${response.status}: ${await response.text()}`);
return response.json();
}
import { randomUUID } from "node:crypto";
import { S3Client, PutObjectCommand, HeadObjectCommand } from "@aws-sdk/client-s3";
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
const bucket = process.env.AVATAR_BUCKET;
const endpoint = process.env.S3_ENDPOINT;
const region = process.env.S3_REGION ?? "auto";
if (!bucket || !endpoint) throw new Error("AVATAR_BUCKET and S3_ENDPOINT are required");
const s3 = new S3Client({ endpoint, region, forcePathStyle: process.env.S3_FORCE_PATH_STYLE === "true" });
const allowedTypes = new Set(["image/jpeg", "image/png", "image/webp"]);
const maxBytes = 5 * 1024 * 1024;
export async function createAvatarUpload(userId: string, contentType: string, size: number) {
if (!allowedTypes.has(contentType) || !Number.isInteger(size) || size < 1 || size > maxBytes) {
throw new Error("Unsupported avatar");
}
const extension = contentType.split("/")[1];
const key = `avatars/${userId}/${randomUUID()}.${extension}`;
const command = new PutObjectCommand({ Bucket: bucket, Key: key, ContentType: contentType, Metadata: { owner: userId } });
const uploadUrl = await getSignedUrl(s3, command, { expiresIn: 600 });
return { key, uploadUrl, expiresInSeconds: 600 };
}
export async function verifyAvatarUpload(userId: string, key: string, expectedType: string) {
if (!key.startsWith(`avatars/${userId}/`)) throw new Error("Avatar key is outside the user scope");
const object = await s3.send(new HeadObjectCommand({ Bucket: bucket, Key: key }));
if (!object.ContentLength || object.ContentLength > maxBytes || object.ContentType !== expectedType) {
throw new Error("Stored avatar failed validation");
}
await saveAvatarKey(userId, key, object.ContentType, object.ContentLength);
return { key };
}
async function saveAvatarKey(userId: string, key: string, contentType: string, size: number) {
console.info({ userId, key, contentType, size });
}
The browser side is deliberately plain:
export async function uploadAvatar(uploadUrl: string, file: File) {
const response = await fetch(uploadUrl, { method: "PUT", headers: { "Content-Type": file.type }, body: file });
if (!response.ok) throw new Error(`Avatar upload failed: ${response.status}`);
}
The client obtains uploadUrl from the application, uploads, then calls application verification. Authentication, ownership, rate limits, and the database transaction belong in those application boundaries. Do not send the application's authorization header to the returned presigned URL.
Browser uploads also require a CORS policy for the origin and method. Some storage products expose direct CORS configuration. Some unified storage APIs do not expose an independent CORS route, so check that before choosing direct upload. A server relay is slower, but it can be the right boundary when browser CORS cannot be configured.
Which storage option fits a solo health product?
There is no universal winner. Compare the workflow, not a feature checklist.
| Option | Useful fit | Boundary to verify |
|---|---|---|
| Amazon S3 | Conventional S3 API with presigned requests and a large ecosystem | Keep the bucket private; define lifecycle, CORS, and deletion explicitly |
| Cloudflare R2 | S3-compatible storage when the application already uses Cloudflare | Confirm the presigning flow and browser CORS for the exact endpoint |
| Alibaba Cloud OSS | Signed URL patterns with an S3-compatible integration path | Confirm signing dialect, region endpoint, and SDK policy behavior |
| Infrai storage | One REST surface when one key and one bill across backend services reduce integration work | It is private and signed-only: no public URL, object versioning, object lock, conditional If-Match, or self-service CORS route |
The first three are strong choices when their existing account, region, compliance process, or operational tooling is already in place. Infrai can fit when a consistent backend interface matters more than a deep storage-specific control plane; its storage surface includes a presign capability. Choose S3, R2, or OSS instead when public hosting, object versioning, object lock, conditional writes, or a mature migration control plane is mandatory. Those are real limits, not reasons to force a unified surface into the wrong job. Its vendor coverage includes R2, S3, OSS, and COS, but not GCS or B2. It has no automatic cross-region replication or bulk cross-cloud migration tool.
For this avatar workflow, missing public-read ACL is a feature. The product should not publish a health user's image as a permanent URL. Missing versioning and object lock matter if an avatar must be recoverable or legally immutable. Missing conditional writes matter when workers can update one key concurrently; use a database state transition or queue coordination.
Limitation: Infrai is not suitable when public hosting, version recovery, object lock, conditional writes, or deep migration tooling is mandatory. In those cases, choose S3, R2, or OSS and accept the extra storage-specific control plane. That trade-off is a better boundary than forcing a unified interface into the wrong job.
The one REST API is a second advantage: plain HTTP works from any language or runtime without installing an SDK, and the public discovery surface is self-describing. Documented capabilities include runnable examples in 10 languages; 295 routes across 20 modules share one credential. That reduces friction when this avatar flow later needs another backend capability, or when a provider changes and the application should keep one interface.
For Infrai, the separate advantage is a pure-HTTP REST API: any language or runtime can use one platform of backend capabilities without installing an SDK, while the public discovery surface describes the interface and its examples. That is a different benefit from one key and one bill.
What I would change at scale
I would add an upload-intent row with an expiry, expected type, expected size, user ID, and generated key. Verification would atomically move it from pending to verified; retries would be harmless because the state transition is idempotent. A queue worker would delete stale pending objects and reconcile verified rows against storage.
I would also separate βavatar currently displayedβ from βobject exists.β On replacement, verify the new object, update the user record, then enqueue deletion of the old key. If deletion runs twice, an already-missing object is completed. More rows and a worker mean more code, but they make retention evidence much easier to explain in a healthtech review. That is a good trade for revenue per hour.
Decision rule
Use presigned URLs when the browser can reach a private S3-compatible endpoint with the required CORS policy. Generate the key on the server. Set a short signing expiry. Verify the stored object. Save only the key.
Choose a deep storage provider when version recovery, object lock, cross-region replication, or mature migration tooling is a hard requirement. Choose a unified REST storage surface when one credential and a consistent interface reduce integration work, and the workflow can live without public objects, versioning, conditional writes, or hourly lifecycle expiry.
The avatar is a small file. The retention decision is the real system.
References
- Amazon Web Services, βUploading objects with presigned URLsβ: https://docs.aws.amazon.com/AmazonS3/latest/userguide/PresignedUrlUploadObject.html
- Amazon Web Services, βChecking object integrityβ: https://docs.aws.amazon.com/AmazonS3/latest/userguide/checking-object-integrity.html
- Cloudflare R2, βPresigned URLsβ: https://developers.cloudflare.com/r2/api/s3/presigned-urls/
- Alibaba Cloud OSS, βUse signed URLs to authorize access to OSS resourcesβ: https://www.alibabacloud.com/help/en/oss/user-guide/authorized-access
- MDN Web Docs, βCache-Control headerβ: https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Cache-Control
Originally published by Dev.to Security. Aggregated on AIWithGhost for educational purposes β full credit and traffic to the original publisher.