📤 File Upload Strategies
Every app eventually needs to accept a file — an avatar, a résumé PDF, a batch of product photos. It looks simple from the browser, but a file upload is one of the most attacked surfaces in a web application. This lesson takes you from the raw HTTP request all the way to a file safely stored on disk, and teaches you to reject the malicious ones along the way.
Week 10 · Wednesday (File Handling) · Lecture 1
🎯 Learning Objectives
By the end of this lesson, you will be able to:
- Explain how a browser packages a file into a
multipart/form-datarequest - Configure Multer and choose deliberately between
memoryStorageanddiskStorage - Enforce a hard file-size limit and reject uploads that exceed it
- Validate the real content type from magic bytes, not just the extension or the client-supplied MIME
- Generate collision-proof, path-traversal-proof filenames with
crypto.randomUUID() - Stream large files instead of buffering them entirely in memory
Estimated Time: 70 minutes
Practice: Build a hardened single-file upload endpoint that validates size, sniffs the file signature, and stores the file under a random name.
In This Lesson
Why Uploads Are Hard
Reading a value from a text field is trivial: the browser sends a short string and you trust it as far as your validation allows. A file is different. It's arbitrary, attacker-controlled binary data of arbitrary size, and it arrives with a name and a type that the client made up. Treat any of that as trustworthy and you open the door to disk-filling denial of service, malware storage, and path-traversal writes that overwrite your own server files.
The good news: the whole pipeline is just four honest steps, and each one has a clear defensive job. Picture the flow before we build it.
Notice where the decision happens: validate before you store. Once a hostile file lands on your disk under a name it chose, the damage may already be done. This lesson lives almost entirely inside that diamond in the middle of the diagram.
How a File Reaches the Server
When a form contains a file, the browser can't use the ordinary URL-encoded body format — binary data doesn't survive that encoding. Instead it switches to multipart/form-data, which splits the request body into labelled parts separated by a boundary string. Think of it as a shipping container: each field is its own boxed parcel with a label, and files ride alongside text fields in the same container.
The HTML side
<!-- The two attributes that make uploads work: -->
<form action="/api/upload" method="POST" enctype="multipart/form-data">
<input type="file" name="avatar" accept="image/png,image/jpeg">
<button type="submit">Upload</button>
</form>
The enctype="multipart/form-data" is the non-negotiable part — forget it and the file never leaves the browser. The accept attribute is a convenience that filters the file picker; it is not security, because anyone can bypass the picker entirely.
Uploading from JavaScript with progress
Most real apps upload with fetch or XMLHttpRequest so they can show a progress bar. FormData builds the multipart body for you and sets the boundary automatically — never set the Content-Type header yourself here, or you'll clobber that boundary.
// Client-side: upload a single file and report progress.
function uploadFile(file) {
const formData = new FormData();
formData.append('avatar', file); // field name must match the server
const xhr = new XMLHttpRequest();
xhr.open('POST', '/api/upload');
xhr.upload.addEventListener('progress', (e) => {
if (e.lengthComputable) {
const percent = Math.round((e.loaded / e.total) * 100);
console.log(`Uploaded ${percent}%`); // wire this to a progress bar
}
});
xhr.onload = () => {
// Do NOT let the server hang the connection open; always respond.
console.log(xhr.status === 200 ? 'Success' : 'Failed', xhr.responseText);
};
xhr.send(formData); // FormData sets Content-Type + boundary
}
📖 Why fetch can't show progress (yet)
The Fetch API has no built-in upload-progress event, which is why the venerable XMLHttpRequest still earns its keep for this one job. You can use fetch for uploads — you just won't get a percentage without a more advanced ReadableStream approach.
Multer: Memory vs Disk
Express doesn't parse multipart bodies on its own. Multer is the standard middleware that does: it reads the parts, exposes text fields on req.body, and hands you file objects on req.file or req.files. The single most important decision you make with Multer is where the bytes go.
memoryStorage when you'll immediately forward the buffer (to S3 or sharp); reach for diskStorage for large files you want to hand to a stream.Memory storage — the buffer lands in RAM
const express = require('express');
const multer = require('multer');
const app = express();
// The whole file is held in memory as a Buffer on req.file.buffer.
const upload = multer({
storage: multer.memoryStorage(),
limits: { fileSize: 5 * 1024 * 1024 } // 5 MB — enforced before RAM fills up
});
app.post('/api/upload', upload.single('avatar'), (req, res) => {
if (!req.file) return res.status(400).json({ error: 'No file uploaded' });
// req.file.buffer is ready to pipe to cloud storage or an image processor.
console.log(req.file.originalname, req.file.size, req.file.mimetype);
res.json({ ok: true, size: req.file.size });
});
Why it matters: memory storage is perfect when the file is a stepping stone — you validate it, transform it, and push it to S3 without ever wanting it on your own disk. The catch is right there in the name: a 4 GB upload becomes 4 GB of RAM. The fileSize limit is what keeps that honest.
Disk storage — bytes stream to a folder
const path = require('path');
const crypto = require('crypto');
const upload = multer({
storage: multer.diskStorage({
destination: (req, file, cb) => cb(null, 'uploads/'),
filename: (req, file, cb) => {
// Never trust file.originalname for the stored name (see the Filenames section).
const ext = path.extname(file.originalname).toLowerCase();
cb(null, `${crypto.randomUUID()}${ext}`);
}
}),
limits: { fileSize: 25 * 1024 * 1024 } // 25 MB
});
⚠️ Store outside the web root
If your uploads folder is served statically, an attacker who uploads a .html or .svg file can get it executed in your users' browsers (stored XSS). Keep uploads outside the publicly served directory and stream them back through a controlled route, or offload them to cloud storage entirely — the subject of the next lesson.
Validating Size & Real Type
Two validations separate a toy endpoint from a production one: how big the file is and what it actually is. Both must be enforced on the server. Client-side checks improve the user experience but stop no attacker.
Size: fail fast with a limit
The limits.fileSize option makes Multer abort the moment the stream crosses the threshold — it never buffers the whole oversized file. When it trips, Multer throws a MulterError with code LIMIT_FILE_SIZE, which you translate into a friendly response.
// Centralized Multer error handler — mount it AFTER your upload routes.
app.use((err, req, res, next) => {
if (err instanceof multer.MulterError) {
if (err.code === 'LIMIT_FILE_SIZE') {
return res.status(413).json({ error: 'File too large (max 5 MB)' });
}
return res.status(400).json({ error: err.message });
}
if (err) return res.status(400).json({ error: err.message });
next();
});
Type: sniff the magic bytes, don't trust the label
Here is the trap most tutorials fall into. Multer's file.mimetype and the file extension both come from the client. An attacker renames virus.exe to photo.jpg and sets the MIME to image/jpeg — your extension check waves it through. Real file types are identified by magic bytes: a signature in the first few bytes of the file. A JPEG always starts with FF D8 FF; a PNG with 89 50 4E 47.
The file-type package inspects the buffer for you. Pair it with memory storage so the bytes are already in hand.
// npm install file-type
const { fileTypeFromBuffer } = require('file-type');
const ALLOWED = new Set(['image/jpeg', 'image/png', 'image/webp', 'application/pdf']);
app.post('/api/upload', upload.single('doc'), async (req, res) => {
if (!req.file) return res.status(400).json({ error: 'No file uploaded' });
// Inspect the actual bytes, not req.file.mimetype (which the client supplied).
const detected = await fileTypeFromBuffer(req.file.buffer);
if (!detected || !ALLOWED.has(detected.mime)) {
return res.status(400).json({
error: 'Unsupported file type',
detected: detected ? detected.mime : 'unknown'
});
}
// detected.ext is the trustworthy extension for the stored filename.
res.json({ ok: true, type: detected.mime, ext: detected.ext });
});
✅ Defense in depth
Use both a Multer fileFilter (a cheap first pass on the claimed MIME) and a magic-byte check on the buffer. The filter rejects obvious junk early; the byte check catches the liar that slipped past it. Belt and suspenders.
Safe Filenames
The name the browser sends is attacker-controlled text. Two things can go wrong if you reuse it verbatim: collisions (two users upload image.png and one overwrites the other) and path traversal (a crafted name like ../../etc/passwd or ..\\..\\config.js escapes your uploads folder and writes somewhere dangerous).
The fix is to throw the original name away for storage and mint a fresh, random one. crypto.randomUUID() is built into modern Node and gives you a globally-unique, unguessable identifier with no special characters.
const crypto = require('crypto');
const path = require('path');
function safeFileName(detectedExt) {
// A UUID can never contain "/", "\\", or ".." — traversal is impossible.
return `${crypto.randomUUID()}.${detectedExt}`;
}
// Keep the human-readable original name ONLY as metadata in your database,
// never as the name on disk:
const record = {
storedName: safeFileName('jpg'), // e.g. "6f9619ff-8b86-...-4f8d.jpg"
originalName: req.file.originalname // display-only, e.g. "My Vacation.jpg"
};
⚠️ Never build a path by concatenating user input
Writing path.join('uploads', req.file.originalname) looks harmless but is exactly the path-traversal hole. Derive the extension from your own magic-byte detection, and build the name from a UUID you generated. If you must preserve the original name for downloads, set it in a Content-Disposition header at serve time — don't put it on the filesystem.
Streaming Large Files
Buffering works beautifully up to a point, and that point is your available memory. A video-upload service that reads each file fully into RAM will fall over under load. The scalable pattern is to stream: process the file in small chunks as they arrive, so memory usage stays flat no matter how large the file is.
With disk storage, Multer has already streamed the file to a path; you then hand that path to a read stream. Think of buffering as filling a bathtub before you can bathe, and streaming as standing under a running shower — you never need to hold all the water at once.
const fs = require('fs');
app.post('/api/upload-large', upload.single('video'), (req, res) => {
if (!req.file) return res.status(400).json({ error: 'No file uploaded' });
// req.file.path points at the file Multer streamed to disk.
const readStream = fs.createReadStream(req.file.path);
readStream.on('error', (err) => {
console.error('Stream error:', err);
res.status(500).json({ error: 'Processing failed' });
});
// Pipe to wherever it needs to go — a hash, a transform, or cloud storage.
const hash = crypto.createHash('sha256');
readStream.on('data', (chunk) => hash.update(chunk));
readStream.on('end', () => {
res.json({ ok: true, checksum: hash.digest('hex') });
});
});
Output
POST /api/upload-large → 200 OK
{ "ok": true, "checksum": "e3b0c44298fc1c149afbf4c8996fb924..." }
Peak memory: ~a few MB, regardless of a 2 GB input
Why it matters: streaming is what lets a modest server accept files far larger than its RAM. When you move to cloud storage in the next lesson, you'll see the same principle taken one step further — the browser streams straight to the bucket and your server barely touches the bytes at all.
Practice & Quiz
🏋️ Exercise 1: A hardened upload endpoint
Goal: Write a POST /api/upload route that accepts a single image, rejects anything over 2 MB, verifies the real type by magic bytes, and stores it under a random UUID name. Only JPEG and PNG should be accepted.
const express = require('express');
const multer = require('multer');
const crypto = require('crypto');
const fs = require('fs/promises');
// TODO: import fileTypeFromBuffer, set up multer memoryStorage with a 2 MB limit,
// validate the buffer, and write it under a UUID filename.
💡 Hint
Use multer.memoryStorage() so the buffer is available for fileTypeFromBuffer(req.file.buffer). Put the size cap in limits.fileSize. Build the filename from crypto.randomUUID() plus detected.ext, then fs.writeFile it into an uploads/ folder that lives outside your public directory.
✅ Solution
const express = require('express');
const multer = require('multer');
const crypto = require('crypto');
const fs = require('fs/promises');
const { fileTypeFromBuffer } = require('file-type');
const app = express();
const ALLOWED = new Set(['image/jpeg', 'image/png']);
const upload = multer({
storage: multer.memoryStorage(),
limits: { fileSize: 2 * 1024 * 1024 } // 2 MB
});
app.post('/api/upload', upload.single('image'), async (req, res) => {
if (!req.file) return res.status(400).json({ error: 'No file uploaded' });
const detected = await fileTypeFromBuffer(req.file.buffer);
if (!detected || !ALLOWED.has(detected.mime)) {
return res.status(400).json({ error: 'Only JPEG or PNG allowed' });
}
const storedName = `${crypto.randomUUID()}.${detected.ext}`;
await fs.writeFile(`uploads/${storedName}`, req.file.buffer);
res.json({ ok: true, storedName, type: detected.mime });
});
app.use((err, req, res, next) => {
if (err instanceof multer.MulterError && err.code === 'LIMIT_FILE_SIZE') {
return res.status(413).json({ error: 'File too large (max 2 MB)' });
}
res.status(400).json({ error: err.message });
});
app.listen(3000, () => console.log('Listening on 3000'));
🏋️ Exercise 2: A reusable file filter
Goal: Write a Multer fileFilter factory that accepts an array of allowed MIME types and rejects everything else, so you can reuse it for images, documents, and audio.
✅ Solution
function allowOnly(mimeTypes) {
const allowed = new Set(mimeTypes);
return (req, file, cb) => {
if (allowed.has(file.mimetype)) return cb(null, true);
// Reject cleanly; the error flows to your Multer error handler.
cb(new Error(`Type ${file.mimetype} is not allowed`), false);
};
}
const imageUpload = multer({
storage: multer.memoryStorage(),
fileFilter: allowOnly(['image/jpeg', 'image/png', 'image/webp']),
limits: { fileSize: 5 * 1024 * 1024 }
});
// Remember: this is the cheap first pass. Still verify magic bytes after.
🎯 Quick Quiz
Question 1: Why should you validate a file's type with magic bytes instead of its extension or req.file.mimetype?
Question 2: When should you prefer Multer's memoryStorage over diskStorage?
Question 3: What is the main risk of storing an uploaded file under its original name?
Best Practices & Pitfalls
✅ Do
- Set a
limits.fileSizeon every Multer instance — no exceptions - Verify the real content type from magic bytes with
file-type - Store files under a random
crypto.randomUUID()name, keeping the original only as metadata - Keep the uploads folder outside your statically-served web root
- Stream files that could be large instead of buffering them whole
❌ Don't
- Trust
file.originalname,file.mimetype, or the extension for security decisions - Concatenate user-supplied names into a filesystem path
- Set the
Content-Typeheader yourself when sendingFormDatafrom the browser - Serve uploaded HTML or SVG from the same origin as your app without sanitizing (stored XSS)
- Forget to send a response on every branch — a hung request ties up a connection
⚠️ The empty-response deadlock
app.post('/api/upload', upload.single('f'), (req, res) => {
if (!req.file) return; // ❌ no response — the browser hangs until timeout
res.json({ ok: true });
});
Every code path must call res.send, res.json, or res.status(...).end(). A silent early return leaves the client waiting.
Summary
🎉 Key Takeaways
- Files travel in a
multipart/form-datarequest;FormDatabuilds it and sets the boundary for you - Multer parses that request — choose
memoryStorageto forward a bounded buffer,diskStorageto scale to large files - Always enforce a size limit, and validate the real type from magic bytes, never the client's claims
- Give every stored file a random UUID name to defeat collisions and path traversal
- Stream large files so memory stays flat under load
📚 Additional Resources
- Multer — official documentation
- MDN — FormData
- MDN — MIME types
- file-type — magic-byte detection on npm
🚀 What's Next?
You can now accept a file safely and store it on your own server — but that server has finite disk and doesn't scale. Next we hand storage off to a dedicated service: Cloud Storage Integration, where you'll upload to Amazon S3 with the AWS SDK v3 and let the browser send files straight to the bucket with presigned URLs.
🎉 Well done!
You've built the defensive backbone of every upload feature you'll ever ship. Everything from avatars to video pipelines starts with exactly these four checks.