Avatar uploads in Node: signed URL or straight through your server?
For a 200 KB profile picture the proxy design wins on validation, resizing and simplicity. When the signed URL is worth it, with runnable Node 22 for both.
Send the avatar through your own server. At the sizes a profile picture actually reaches — 20 KB to maybe 2 MB before you resize it — the bandwidth you save with a signed upload URL is bandwidth you were never going to notice, and you give up the one place where you can check that the “PNG” isn’t a 40 MB TIFF. Infrai stores the resized result in a single authenticated call, PUT /v1/storage/object/put/{bucket}/{key}, so the proxy path is two functions, not an architecture.
The signed-URL design earns its keep somewhere else: files large enough that a Node process holding them becomes your bottleneck, or a native mobile client uploading on a flaky connection. Neither describes an avatar. And on Infrai buckets there’s a further wrinkle for web clients, which we’ll get to.
The two designs, honestly compared
| Proxy through your server | Presigned PUT from the client | |
|---|---|---|
| Round trips | One | Two (mint, then upload) |
| Can you validate the bytes? | Yes, before anything is stored | Only after the fact, via head |
| Can you resize before storing? | Yes | No — you store twice or run a worker |
| Server memory | Holds the file briefly | None |
| Works from a browser on an Infrai bucket | Yes | No, the preflight has no rule to match |
| Billable calls per upload | 1 write | 1 write (free presign) |
| Sensible size range | Up to a few MB | Tens of MB and up |
The proxy route, end to end
Multer holds the upload in memory with a hard cap, sharp normalises it to a square WebP, and one call stores it. The key comes from the session — never from the filename the browser sent.
import express from "express";
import multer from "multer";
import sharp from "sharp";
import { requireSession } from "./auth.mjs";
const API = "https://api.infrai.cc";
const BUCKET = "kb-profile-0726";
const token = process.env.INFRAI_API_KEY;
if (!token) throw new Error("INFRAI_API_KEY is not set");
const app = express();
const upload = multer({ limits: { fileSize: 4 * 1024 * 1024 } });
const ACCEPTED = new Set(["image/png", "image/jpeg", "image/webp"]);
app.post("/api/avatar", requireSession, upload.single("avatar"), async (req, res) => {
if (!req.file) return res.status(400).json({ error: "no file" });
if (!ACCEPTED.has(req.file.mimetype)) return res.status(415).json({ error: "png, jpeg or webp only" });
try {
const meta = await sharp(req.file.buffer).metadata();
if (!meta.width || meta.width < 64) return res.status(422).json({ error: "image too small" });
const square = await sharp(req.file.buffer)
.resize(256, 256, { fit: "cover" })
.webp({ quality: 85 })
.toBuffer();
const key = `avatars/${req.user.id}/256.webp`;
const payload = { data_base64: square.toString("base64"), content_type: "image/webp" };
const stored = await fetch(`${API}/v1/storage/object/put/${BUCKET}/${key}`, {
method: "PUT",
headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
const json = await stored.json();
if (!stored.ok || json.ok === false) throw new Error(json?.error?.code ?? `HTTP ${stored.status}`);
res.status(201).json({ key: json.data.key, etag: json.data.etag, size_bytes: json.data.size_bytes });
} catch (err) {
console.error("avatar upload failed", err);
res.status(502).json({ error: "could not store avatar" });
}
});
app.listen(3000);
Note what the resize buys beyond looks: whatever the user sent, what lands in the bucket is a 256×256 WebP of predictable size. An upload path that stores the original is an upload path where one user’s 12-megapixel holiday photo becomes everyone else’s page-load problem.
Validation is the real argument, not bandwidth
Content-Type is a claim the client makes. Anyone can send image/png on a zip file, and a bucket will store it happily — Infrai’s blocklist returns STORAGE_CONTENT_TYPE_BLOCKED for the obviously dangerous types, but it can’t police a payload disguised as a picture.
Decoding the image is the check. If sharp().metadata() throws, it isn’t an image, and you found out before it had a key in your bucket rather than after some support ticket.
That’s the part a presigned upload structurally cannot do.
The signed-URL variant, and what breaks it in a browser
Minting the slot is one free call. The client then PUTs the bytes to the returned URL, echoing the headers verbatim:
export INFRAI_API_KEY="your_infrai_api_key"
curl -sS -X POST \
"https://api.infrai.cc/v1/storage/object/presign/kb-profile-0726/avatars/usr_5540/original.png" \
-H "Authorization: Bearer ${INFRAI_API_KEY}" \
-H "Content-Type: application/json" \
-d '{"op":"put","expires_seconds":600,"content_type":"image/png","max_bytes":4194304}'
{
"ok": true,
"data": {
"url": "https://<vendor-host>/<bucket>/avatars/usr_5540/original.png?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Expires=600&X-Amz-Signature=...",
"method": "PUT",
"headers": { "Content-Type": "image/png" },
"fields": null,
"expires_at": "2026-07-26T00:41:37.834178Z",
"max_bytes": 4194304
}
}
max_bytes is enforced by the signature rather than by your handler, which is a genuinely nice property — a client that lies about its Content-Length still can’t overrun the cap.
The catch is the browser. A cross-origin PUT triggers a preflight, GET /v1/storage/bucket/get/{bucket} shows cors_rules as an empty list, and there’s no route to write them, so a web page can’t complete that upload today. Native apps and your own server can, because neither preflights. If browser-direct upload is the requirement, use Amazon S3 or Cloudflare R2 where the rule set is yours to edit.
Confirm it landed
Never trust the client’s success callback. head is free, doesn’t transfer the body, and tells you the truth:
curl -sS \
"https://api.infrai.cc/v1/storage/object/head/kb-profile-0726/avatars/usr_5540/original.png" \
-H "Authorization: Bearer ${INFRAI_API_KEY}"
{
"ok": true,
"data": {
"found": true,
"status": "found",
"key": "avatars/usr_5540/original.png",
"size_bytes": 20,
"etag": "8b1a9953c4611296a827abf8c47804d7",
"content_type": "image/png",
"last_modified": "2026-07-26T00:36:19Z"
}
}
A found of false after a “successful” upload means the bytes went somewhere you didn’t intend — usually a key built from an unescaped filename.
What an avatar costs per user
One upload is one billable write. Verified 26 July 2026, storage.object.put bills $0.0001 per call and storage.object.get $0.0002; presigning, head and list are free and rate-limited, and a new account starts with $2 of credit — roughly 19,999 free writes before you’ve spent anything. A 256×256 WebP is around 12 KB, so ten thousand users is about 120 MB of stored bytes: real money at the third decimal place.
curl -sS https://api.infrai.cc/v1/discovery \
-H "Authorization: Bearer ${INFRAI_API_KEY}" \
| python3 -c "import json,sys; [print(c['id'], c['billing'].get('price_usd','free')) for c in json.load(sys.stdin)['capabilities'] if c['id'].startswith('storage.object')]"
Storage rates move down over time and campaigns run, so treat the figure above as a reading rather than a constant. The structural claim is steadier: the write is billed per call, the signature isn’t billed at all, and the same key covers the queue, the cron and the error tracking around this feature instead of adding three more invoices.
Where another tool is a better fit
If you want avatars cropped, face-centred and served through a transform URL, Cloudinary does that as a product and building it yourself is a poor use of a sprint. If you’re already on Supabase, its storage client is one dependency you’ve paid for. And for browser-direct uploads of genuinely large media, S3 with a CORS rule remains the well-trodden path. For “let users change their profile picture” on a Node backend, the proxy design above is 60 lines and no new vendor.