
File uploads are one of the highest risk operations in web development, yet many backends still treat them as an afterthought. After building and hardening a Multer based upload system for a Node.js backend, I want to share the architecture, the attack vectors it defends against, and the tradeoffs behind key decisions like memory versus disk storage.
Why Naive File Upload Handling Is Dangerous
A default setup like multer({ dest: 'uploads/' }) accepts any file, preserves whatever name the client sends, and gives no protection against several well known risks:
Remote code execution, if an executable script lands in an accessible directory. Stored cross site scripting, since HTML or SVG files can carry embedded scripts. MIME type spoofing, where an attacker labels a malicious file as an image. Denial of service through unconstrained payload sizes. Path traversal, where a filename like ../../etc/passwd can overwrite system files. And double extension attacks, where a file named invoice.pdf.exe slips past careless checks.
None of these are hypothetical. They are the standard checklist an attacker runs through against any upload endpoint.
The production implementation addresses these risks with several independent layers rather than a single check:
Strict MIME whitelisting, where only explicit image, document, audio, and video types are accepted. MIME to extension cross checking, so a declared image/jpeg file must actually end in .jpg or .jpeg. A blocklist of over 20 dangerous extensions, including .exe, .php, .js, .svg, and .sh, rejected unconditionally regardless of the declared MIME type. Filename sanitization that strips control characters, null bytes, and path separators before a file ever touches storage. Strict size caps enforced early, returning HTTP 413 before a payload can exhaust server resources. And memory storage as the default, so buffers can be piped straight to cloud storage or a background worker without touching local disk.
One of the more consequential architectural decisions is choosing between multer.memoryStorage() and multer.diskStorage(). Memory storage is the default because it fits stateless container deployments well: there is nothing to clean up, since the V8 garbage collector reclaims the buffer once the request completes, and files stream directly into services like AWS S3 or a BullMQ queue.
Disk storage becomes necessary under specific conditions. Node's V8 engine has a default heap ceiling of roughly 1.4 to 2GB on 64 bit systems. If 15 concurrent users each upload a 40MB file into memory, that is 600MB of binary data before accounting for base64 conversions or request overhead, which can push a container past its memory limit and trigger an out of memory kill. Disk storage avoids this by streaming incoming bytes to the filesystem in small 64KB chunks, keeping RAM usage flat regardless of file size.
Disk storage is also required whenever a tool needs a real file path rather than a buffer. Tools like ffmpeg for video transcoding, antivirus scanners like clamscan, and document processors like ghostscript or Tesseract OCR all expect a path on disk. The same applies to high volume batch imports: streaming a 100MB CSV file line by line keeps memory usage under roughly 30MB, whereas buffering the same file in memory would push past 1GB.
Disk storage additionally supports job resumability. In a multi step pipeline such as upload, virus scan, thumbnail generation, transcode, and upload to S3, a memory buffer vanishes if a worker process restarts mid job, forcing the user to upload again. A file persisted to disk lets a BullMQ retry worker resume from the point of failure without any user interaction.
When disk storage is used for background processing, the system follows a staged lifecycle to avoid orphaned files. On success, the worker streams the file to S3 and deletes it from disk immediately. On a transient failure, the file is left in place while BullMQ retries with exponential backoff, so the same file can be reused on the next attempt. If all retries are exhausted, the file is deleted immediately to prevent permanent disk exhaustion, a critical alert is logged, and the job moves to a dead letter queue. As a final safety net, a recurring cleanup job sweeps the temp directory and purges any file older than 24 hours, in case a container crash prevents normal cleanup from running.
SVG is often overlooked as an image format, but it is actually an XML document that can contain a script tag. Serving an uploaded SVG from the same domain without sanitization means any embedded script executes in the viewer's browser, a straightforward stored XSS vector. Unless a sanitizer such as DOMPurify is integrated, SVG uploads are blocked outright rather than treated as safe images.
A production ready upload system is not one check, it is a stack of independent defenses: MIME whitelisting, extension cross checking, a dangerous extension blocklist, filename sanitization, size limits, and a deliberate choice between memory and disk storage based on file size, downstream tooling, and failure recovery needs. Treating file uploads with this level of rigor from the start avoids a category of vulnerabilities that are common, well documented, and entirely preventable.