TL;DR
Never trust a file extension or the MIME type the browser sent you. Both are strings the uploader chose. Read the actual bytes, rename the file yourself, keep it out of the web root, and cap the size before any of that runs.
"Every uploaded file is a potential attack vector. Treat uploads like untrusted user input. Validate everything, trust nothing."
Best Practice 1: Validate File Type 3 min
shell.php.jpg has a .jpg extension and will happily report image/jpeg if the uploader says so. Read the bytes instead. The first few of a real JPEG are fixed, and that's what file-type checks.
import { fileTypeFromBuffer } from 'file-type';
const ALLOWED_TYPES = {
'image/jpeg': ['.jpg', '.jpeg'],
'image/png': ['.png'],
'image/webp': ['.webp'],
'application/pdf': ['.pdf'],
};
const MAX_SIZE = 10 * 1024 * 1024; // 10MB
async function validateFile(file) {
const errors = [];
// Check size
if (file.size > MAX_SIZE) {
errors.push('File too large (max 10MB)');
}
// Read file buffer to check actual type
const buffer = await file.arrayBuffer();
const fileType = await fileTypeFromBuffer(buffer);
if (!fileType || !ALLOWED_TYPES[fileType.mime]) {
errors.push('Invalid file type');
return { valid: false, errors };
}
// Verify extension matches actual type
const ext = file.name.split('.').pop()?.toLowerCase();
if (!ALLOWED_TYPES[fileType.mime].includes(`.${ext}`)) {
errors.push('File extension does not match content');
}
return {
valid: errors.length === 0,
errors,
mimeType: fileType.mime,
};
}
Best Practice 2: Generate Safe Filenames 2 min
The filename is user input too. Generate your own from the MIME type you just verified. Keep the original in a database column, where it can only be displayed.
import { randomUUID } from 'crypto';
import path from 'path';
function generateSafeFilename(originalName, mimeType) {
// Get extension from MIME type, not original filename
const extensions = {
'image/jpeg': 'jpg',
'image/png': 'png',
'image/webp': 'webp',
'application/pdf': 'pdf',
};
const ext = extensions[mimeType];
const uuid = randomUUID();
return `${uuid}.${ext}`;
// WRONG: Using original filename
// return originalName; // Could be "../../../etc/passwd.jpg"
}
// Store original filename in database if needed
await db.file.create({
data: {
storedName: safeFilename,
originalName: file.name,
mimeType: fileType.mime,
size: file.size,
userId: user.id,
},
});
Best Practice 3: Store Outside Web Root 3 min
If a file sits at a predictable URL it's public, whatever your app thinks. Store uploads where the web server doesn't serve, and hand them out through an endpoint that checks who's asking.
// WRONG: Storing in public directory
const uploadPath = './public/uploads/' + filename;
// File accessible at: https://site.com/uploads/file.jpg
// CORRECT: Store outside web root, serve via API
const uploadPath = './private-uploads/' + filename;
// Serve files through authenticated endpoint
app.get('/api/files/:id', authenticate, async (req, res) => {
const file = await db.file.findUnique({
where: { id: req.params.id },
});
// Check authorization
if (file.userId !== req.user.id) {
return res.status(403).json({ error: 'Access denied' });
}
const filePath = path.join('./private-uploads', file.storedName);
res.sendFile(filePath);
});
Best Practice 4: Use Cloud Storage 5 min
S3 and GCS solve the previous two problems for you, as long as the bucket is private. Presigned URLs give a specific person a specific file for a specific hour.
import { S3Client, PutObjectCommand, GetObjectCommand } from '@aws-sdk/client-s3';
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
const s3 = new S3Client({ region: process.env.AWS_REGION });
async function uploadToS3(file, filename) {
const command = new PutObjectCommand({
Bucket: process.env.S3_BUCKET,
Key: `uploads/${filename}`,
Body: file,
ContentType: file.type,
});
await s3.send(command);
}
// Generate signed URL for download (temporary access)
async function getDownloadUrl(filename) {
const command = new GetObjectCommand({
Bucket: process.env.S3_BUCKET,
Key: `uploads/${filename}`,
});
return getSignedUrl(s3, command, { expiresIn: 3600 }); // 1 hour
}
Best Practice 5: Scan for Malware 4 min
Worth it once you accept documents rather than just images. Re-encoding an image already neutralises most payloads; a PDF or an Office file passes through untouched.
// Using ClamAV
import NodeClam from 'clamscan';
const clamscan = await new NodeClam().init({
removeInfected: true,
scanLog: '/var/log/clamscan.log',
});
async function scanFile(filePath) {
const { isInfected, viruses } = await clamscan.scanFile(filePath);
if (isInfected) {
console.warn('Infected file detected:', viruses);
await fs.unlink(filePath); // Delete infected file
throw new Error('Malicious file detected');
}
return true;
}
Common File Upload Mistakes
| Mistake | Risk | Prevention |
|---|---|---|
| Trusting extensions | Malicious file execution | Check actual file content |
| Using original filename | Path traversal attacks | Generate random names |
| Storing in web root | Direct file access | Store outside, serve via API |
| No size limits | DoS via large uploads | Enforce strict size limits |
| No rate limiting | Storage exhaustion | Limit uploads per user |
External Resources: OWASP maintains two references worth reading in full: the File Upload Cheat Sheet and the Unrestricted File Upload page. Both go deeper on the parser-level attacks this post only gestures at.
Should I resize images on upload?
Yes. Re-encoding through Sharp drops the original bytes, and any embedded payload goes with them. Smaller files and faster pages are the bonus, not the reason.
How do I handle large file uploads?
Multipart with resumable support. Better still, hand the browser a presigned URL so a 2GB file uploads straight to S3 and never touches your server.
Should I keep original filenames?
Keep them in the database so you can show the user what they uploaded. Never let one reach the filesystem. The name on disk is yours to generate.
Further Reading
The upload endpoint is rarely the only thing that needs tightening.
Check Your File Upload Security
Scan your application for file upload vulnerabilities.