Handling Image Uploads on the Server
Node image upload security: validate file signatures in memory, enforce upload size limits, re-encode with sharp, and serve files safely.
Server-side file upload validation starts once the multipart request arrives, and by then the only thing worth trusting is the bytes. The Content-Type and the filename both come from the browser, so treat them as claims rather than facts. If you arrived here from the browser-side pieces on creating image thumbnails before uploading and converting images to Base64 with Canvas, this is the receiving end of that pipeline.
The two halves of the pipeline do different jobs. Browser-side resizing and compression are a courtesy to the user; server-side validation is what stops a 2 GB body or a script wearing a .jpg extension. This article walks through five controls a Node image endpoint needs: signature checks, layered size limits, generated filenames, re-encoding, and safe serving.
Key Takeaways
- The Content-Type header and the filename in a multipart upload are both set by the client, so a server that checks either one is validating the attacker’s claim about the file, not the file itself.
- A signature check should read the file’s leading bytes in memory, before anything touches disk, and compare them against the formats this specific endpoint accepts.
- Size limits belong at the proxy first, in Multer’s
limitsoption second, and in application code last; Multer’sfileSizedefaults to Infinity, so an unset limit is no limit. - Re-encoding with sharp is what makes signature spoofing irrelevant: the output is a new file, and EXIF metadata, including GPS coordinates from phone photos, does not survive it.
- Serve stored images with a Content-Type from your own validation record,
X-Content-Type-Options: nosniff, and a storage path outside the web root.
Why Do MIME Type and Extension Checks Fail?
Checking file.mimetype or the filename extension does not validate the file, because the client supplies both values. Two earlier articles on this blog gave that advice: Multer NPM: File Upload in Node.js shows a fileFilter that accepts any file whose mimetype appears in an allowed list, and Safe User Input Handling in Node.js tells readers to validate MIME types at the parser level. Both checks are worth keeping as cheap early rejections, but neither is a security control.
The forgery takes one line:
curl -F "file=@payload.sh;type=image/jpeg" https://example.com/upload
Multer copies that type value straight into req.file.mimetype. Your filter sees image/jpeg; the body is a shell script. The OWASP File Upload Cheat Sheet is explicit that validation must be based on file content, not client-supplied metadata.
File Upload Validation Starts with the File Signature
Read the leading bytes of the buffer, in memory, before anything is written to disk, and compare them against the formats this specific endpoint accepts. Both halves of that sentence matter. Tutorials commonly validate after the file has already landed in an uploads directory, which means an oversized or hostile payload has done its damage before the check runs. And they commonly pass a file if it matches any known signature, so a PDF sails through an avatar endpoint. An avatar endpoint that recognizes PDFs has a validation bug, not a feature.
The signatures themselves: JPEG begins FF D8 FF, PNG begins with the full 8-byte sequence defined in the PNG specification, and WebP requires RIFF at offset 0 plus WEBP at bytes 8 through 11, per the WebP container spec. Bytes 4 through 7 are the RIFF chunk size, which is why a WebP check must skip them, and why a naive 4-byte read handles neither PNG nor WebP correctly.
const SIGNATURES = {
"image/jpeg": (b) => b.length >= 3 && b[0] === 0xff && b[1] === 0xd8 && b[2] === 0xff,
"image/png": (b) =>
b.length >= 8 &&
b.subarray(0, 8).equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])),
"image/webp": (b) =>
b.length >= 12 &&
b.subarray(0, 4).toString("ascii") === "RIFF" &&
b.subarray(8, 12).toString("ascii") === "WEBP",
};
// Allowlist is per endpoint: this one accepts photos, nothing else
function detectImageType(buffer, allowed = ["image/jpeg", "image/png", "image/webp"]) {
return allowed.find((type) => SIGNATURES[type](buffer)) ?? null;
}
One caveat: signatures can be forged by prepending the right bytes to any payload, in seconds. The file-type package says as much in its own README, where matching magic numbers is described as a clue about the format rather than proof of it. Treat the signature check as a fast, cheap filter. The security boundary comes two sections down.
Where Should Upload Size Limits Live?
Size limits belong at the proxy first, the framework second, and application code last, because a check that runs after the body has been buffered rejects the upload only after the full payload has already cost you memory and bandwidth. In nginx, client_max_body_size defaults to 1 MB and answers oversized requests with a 413 before your process sees them:
client_max_body_size 5m;
The framework layer is Multer (2.x), where limits.fileSize defaults to Infinity, so an unset limit is no limit:
const upload = multer({
storage: multer.memoryStorage(),
limits: { fileSize: 5 * 1024 * 1024, files: 1 },
});
When the cap is hit, Multer raises a LIMIT_FILE_SIZE error. On Fastify, @fastify/multipart takes the opposite stance and defaults fileSize to 1 MiB, so the safe behavior is the default rather than the opt-in. Session replays of upload flows make the cost of skipping the proxy layer visible as a UX defect: the progress bar reaches 100 percent, then the rejection appears, because the whole payload had to arrive before the application-level check could run.
Discard the Client’s Filename
Never let the client’s filename touch your filesystem. Generate your own from crypto.randomUUID() plus the extension your own validation determined:
const storedName = `${crypto.randomUUID()}.jpg`;
The reasoning, from traversal sequences to why path.normalize is not a defense, is covered in Preventing Path Traversal Attacks in Node.js; the generated-name approach makes the whole attack class unreachable.
Re-encode the Image; Don’t Store the Original Bytes
Re-encoding is the control that makes signature spoofing irrelevant. sharp (0.35.x) decodes the pixels and writes a brand-new file, so whatever was prepended, appended, or hidden inside the original never survives. It also closes a privacy leak: phone photos routinely carry GPS coordinates in their EXIF data, and storing the original bytes means republishing your user’s location. The sharp output docs spell out the default: nothing from the input’s metadata reaches the output unless you ask for it back with keepExif() or withMetadata(). The orientation flag goes with everything else, so auto-orient first with .autoOrient() or phone photos come out sideways:
let clean;
try {
clean = await sharp(req.file.buffer)
.autoOrient() // apply EXIF orientation before metadata is stripped
.jpeg({ quality: 85 })
.toBuffer();
} catch {
return res.status(422).send("Not a decodable image"); // decode failure is a rejection
}
A buffer that passed the signature check but fails to decode was lying about its format. That is the check working.
Serve What You Validated, Not What You Received
When serving stored images, set the Content-Type from your validation record, never from anything the client sent, add X-Content-Type-Options: nosniff so the browser cannot second-guess it, and store files outside the web root so nothing uploaded is ever directly executable or renderable:
app.get("/images/:id", async (req, res) => {
const record = await getImageRecord(req.params.id); // contentType saved at validation time
if (!record) return res.sendStatus(404);
res.setHeader("Content-Type", record.contentType);
res.setHeader("X-Content-Type-Options", "nosniff");
res.sendFile(record.storedName, { root: UPLOAD_DIR }); // UPLOAD_DIR is outside the web root
});
A few adjacent topics, each one line:
- Antivirus scanning (ClamAV or a cloud equivalent) matters when you accept arbitrary documents, not re-encoded images.
- If you ever accept archives, check the decompressed size before extracting; zip bombs are small files that expand enormously.
- SVG is an XML document that can carry script, so exclude it from image endpoints entirely.
- Presigned-URL architectures upload straight to object storage, which moves this whole pipeline into a post-upload processing step rather than eliminating it.
Wrapping Up
The five controls form one pipeline: reject on size at the proxy, check the signature in memory against this endpoint’s allowlist, re-encode with sharp, store under a name you generated, and serve with headers you control.
| Control | Where it runs | What it stops |
|---|---|---|
| Size limit | Proxy first, Multer limits second, app code last | Oversized bodies consuming memory and bandwidth |
| Signature check | In memory, before anything touches disk | Bytes that don’t match this endpoint’s allowlist |
| Re-encode with sharp | After validation, before storage | Forged signatures, hidden payloads, EXIF GPS data |
| Generated filename | At storage time | Path traversal through the client’s filename |
| Validated serving headers | On every read | MIME sniffing and execution from the web root |
The signature check filters cheaply; the re-encode is the boundary that holds even when the signature was forged. Start by checking your own Multer config: if limits.fileSize is unset, that endpoint currently accepts files of unlimited size.
FAQs
Does the file-type npm package replace a hand-written signature check?
It replaces the byte comparison, not the security model. file-type reads the same magic numbers, and its README is blunt about what that buys you: a match is a clue, and it settles neither whether the file really is that type nor whether it is well formed. You still need an endpoint-specific allowlist on top, because it recognizes hundreds of formats, and re-encoding remains the real boundary. Note the package is ESM-only, so CommonJS projects need a dynamic import or the load-esm workaround.
Does Multer's fileSize limit stop the client from sending the rest of the file?
Not reliably. When limits.fileSize is reached, Multer stops buffering and raises a LIMIT_FILE_SIZE error, which protects your process from unbounded memory use, but nothing in the docs guarantees the network transfer is cancelled, and the error can surface only after all bytes have arrived. A proxy-level cap such as nginx client_max_body_size is what actually protects bandwidth, which is why the limit belongs at the proxy first.
How do you validate uploads when clients use presigned URLs to upload directly to object storage?
Validation moves to a post-upload step instead of disappearing. The client uploads into a quarantine bucket or prefix that nothing serves from, then a background worker or storage-triggered function downloads the object, runs the same signature check and sharp re-encode, and writes the clean output to the public location under a generated name. Objects that fail validation are deleted, and the quarantine location is never exposed to browsers.
Does re-encoding with sharp change the image's colors?
It can for wide-gamut images. Left alone, sharp gives you an sRGB output with no profile attached, since the metadata strip takes the embedded ICC profile with it, so photos authored in Display P3 or Adobe RGB may shift slightly. To preserve colour without reintroducing EXIF data, call keepIccProfile() before encoding. keepMetadata() also retains the profile but brings back everything, including the GPS coordinates the strip was protecting your users from.