How to Handle File Uploads Securely in Node.js
File Uploads Are a Security Minefield
Every file upload endpoint is a potential attack vector. Unrestricted uploads have led to some of the most devastating security breaches in web history. Malicious executables disguised as images, path traversal attacks through filenames, and denial of service through oversized files are just the beginning.
I have hardened file upload systems for several production applications, and here is the approach I use every time.
Setting Up Multer with Strict Limits
Multer is the standard file upload middleware for Express and Node.js. But the default configuration is far too permissive for production use.
const multer = require('multer');
const path = require('path');
const crypto = require('crypto');
const ALLOWED_MIMES = [
'image/jpeg', 'image/png', 'image/webp',
'application/pdf', 'text/csv'
];
const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB
const storage = multer.diskStorage({
destination: (req, file, cb) => {
cb(null, '/tmp/uploads');
},
filename: (req, file, cb) => {
const uniqueName = crypto.randomBytes(16).toString('hex');
const ext = path.extname(file.originalname).toLowerCase();
cb(null, `${uniqueName}${ext}`);
}
});
const upload = multer({
storage,
limits: { fileSize: MAX_FILE_SIZE, files: 5 },
fileFilter: (req, file, cb) => {
if (!ALLOWED_MIMES.includes(file.mimetype)) {
return cb(new Error('File type not allowed'), false);
}
cb(null, true);
}
});
Why Random Filenames Matter
Never use the original filename. Attackers craft filenames with path traversal characters like ../../etc/passwd or shell injection characters. By generating a random hex string, you eliminate an entire class of attacks.
Validating File Content, Not Just Extensions
MIME type checking based on the Content-Type header is not enough. Attackers can set any MIME type they want. You need to inspect the actual file bytes.
const { fileTypeFromBuffer } = require('file-type');
const fs = require('fs').promises;
async function validateFileContent(filepath, expectedMimes) {
const buffer = await fs.readFile(filepath);
const type = await fileTypeFromBuffer(buffer);
if (!type || !expectedMimes.includes(type.mime)) {
await fs.unlink(filepath); // Delete invalid file
throw new Error(`Invalid file content: ${type?.mime || 'unknown'}`);
}
return type;
}
The file-type library reads magic bytes from the file header to determine the actual file type, regardless of what the client claims.
Virus Scanning with ClamAV
For applications handling user-uploaded files, virus scanning is not optional. I integrate ClamAV through the clamscan library:
const NodeClam = require('clamscan');
const clam = await new NodeClam().init({
clamdscan: {
socket: '/var/run/clamav/clamd.ctl',
timeout: 30000
}
});
async function scanFile(filepath) {
const { isInfected, viruses } = await clam.isInfected(filepath);
if (isInfected) {
await fs.unlink(filepath);
console.error(`Virus detected: ${viruses.join(', ')}`);
throw new Error('File failed security scan');
}
}
Image Processing for Safety
For image uploads specifically, I always re-encode the image using Sharp. This strips any embedded malicious payloads, EXIF data that might contain sensitive location information, and polyglot file tricks.
const sharp = require('sharp');
async function sanitizeImage(inputPath, outputPath) {
await sharp(inputPath)
.resize(2048, 2048, { fit: 'inside', withoutEnlargement: true })
.jpeg({ quality: 85 })
.toFile(outputPath);
// Delete the original
await fs.unlink(inputPath);
}
The Complete Upload Endpoint
app.post('/api/upload', upload.single('file'), async (req, res) => {
try {
if (!req.file) {
return res.status(400).json({ error: 'No file provided' });
}
// Validate actual file content
const fileType = await validateFileContent(
req.file.path, ALLOWED_MIMES
);
// Virus scan
await scanFile(req.file.path);
// Sanitize images
let finalPath = req.file.path;
if (fileType.mime.startsWith('image/')) {
finalPath = req.file.path + '.sanitized.jpg';
await sanitizeImage(req.file.path, finalPath);
}
// Move to permanent storage
const permanentPath = await moveToStorage(finalPath);
res.json({
success: true,
url: `/files/${path.basename(permanentPath)}`
});
} catch (error) {
// Clean up on failure
if (req.file?.path) {
await fs.unlink(req.file.path).catch(() => {});
}
res.status(400).json({ error: error.message });
}
});
Storage Best Practices
Never serve uploaded files from your application directory. Use a separate storage location, ideally an object store like S3 with a CDN in front. If you must serve from disk:
- Store files outside the web root
- Serve through a dedicated static file handler with correct Content-Type headers
- Set
Content-Disposition: attachmentfor non-image files - Use a separate domain or subdomain for user content
Production Checklist
- Rate limit upload endpoints aggressively
- Set request body size limits at the reverse proxy level too
- Log all upload attempts with file metadata for audit trails
- Implement file quotas per user
- Clean up temporary files on application crash using a cleanup cron job
- Never execute or interpret uploaded files server-side
File uploads are one of those features where cutting corners will eventually cost you. Build the security in from day one, and you will sleep much better at night.