The Complete Guide to Secure File Uploads in Node.js
A practical, battle-tested approach to handling file uploads without getting your server compromised.
Introduction
File uploads are one of the most common features in web applications—and one of the most dangerous. A poorly implemented upload endpoint can lead to:
- Remote Code Execution (RCE) - Attackers uploading malicious scripts
- Path Traversal - Overwriting system files
- Denial of Service - Filling your disk with huge files
- XSS Attacks - Hosting malicious JavaScript
- Data Breaches - Accessing sensitive files
In this guide, we'll start with a naive implementation, identify its vulnerabilities, and progressively build a production-ready, secure file upload system.
Part 1: The Naive Implementation
Let's look at a typical file upload endpoint that many developers might write:
// DO NOT USE THIS CODE - It has multiple security vulnerabilities
import fastify from "fastify";
import multipart from "@fastify/multipart";
import { writeFile, mkdir } from "fs/promises";
import { join } from "path";
const server = fastify();
server.register(multipart);
const UPLOAD_DIR = "./uploads";
server.post("/upload/avatar", async (request, reply) => {
try {
const data = await request.file();
if (!data) {
return reply.code(400).send({ error: "No file uploaded" });
}
// "Validate" file type by checking MIME type
if (!data.mimetype.startsWith("image/")) {
return reply.code(400).send({ error: "Only images allowed" });
}
// Save the file
const filepath = join(UPLOAD_DIR, data.filename);
await mkdir(UPLOAD_DIR, { recursive: true });
await writeFile(filepath, await data.toBuffer());
return reply.send({
success: true,
url: `/uploads/${data.filename}`
});
} catch (error) {
return reply.code(500).send({
error: "Upload failed",
details: error.message
});
}
});
Can You Spot the Vulnerabilities?
Before reading further, try to identify as many security issues as you can in this code.
Part 2: The Vulnerabilities Exposed
Here are 9 critical security flaws in our naive implementation:
1. No Authentication CRITICAL
server.post("/upload/avatar", async (request, reply) => {
// Anyone can upload!
Attack: An attacker can flood your server with uploads, filling disk space and costing you money.
Impact: Denial of Service, resource exhaustion
2. MIME Type Spoofing CRITICAL
if (!data.mimetype.startsWith("image/")) {
Attack: MIME types come from the client and are trivially spoofed:
curl -X POST \
-H "Content-Type: multipart/form-data" \
-F "file=@malware.exe;type=image/png" \
http://yourserver/upload/avatar
Impact: Malicious files bypass your "validation"
3. Path Traversal Attack CRITICAL
const filepath = join(UPLOAD_DIR, data.filename);
Attack: An attacker can use data.filename to escape the upload directory:
# Overwrite system files or access sensitive data
curl -F "file=@evil.png;filename=../../../etc/passwd" ...
curl -F "file=@evil.png;filename=../../app/.env" ...
Impact: Arbitrary file overwrite, sensitive data exposure
4. Uncontrolled File Size MAJOR
await writeFile(filepath, await data.toBuffer());
Attack: Upload a 10GB file and watch your server crash:
dd if=/dev/zero of=huge.bin bs=1G count=10
curl -F "file=@huge.bin" http://yourserver/upload/avatar
Impact: Disk exhaustion, memory exhaustion, DoS
5. Dangerous File Extensions MAJOR
// No extension validation!
Attack: Upload executable files:
curl -F "file=@shell.php;type=image/png" ...
curl -F "file=@malware.exe;type=image/png" ...
Impact: If the upload directory is web-accessible and executes scripts, you get RCE
6. Double Extension Attack MAJOR
// Filename: innocent.jpg.php
// Would pass image validation but execute as PHP on some servers
Impact: Code execution on misconfigured servers
7. Error Information Leakage MODERATE
return reply.code(500).send({
error: "Upload failed",
details: error.message // Exposes internal details!
});
Attack: Provoke errors to learn about your system:
{
"error": "Upload failed",
"details": "ENOENT: no such file or directory, open '/var/www/app/uploads/'"
}
Impact: Information disclosure helps attackers plan further attacks
8. Missing Extension Validation MODERATE
No validation that the file extension matches the content.
9. No Virus Scanning MODERATE
Uploaded files could contain malware that infects other users who download them.
Part 3: Building Secure File Uploads
Now let's implement each security layer properly.
Layer 1: Authentication
Always require authentication for uploads:
// CORRECT: Verify JWT and check blacklist
async function authenticateRequest(request, reply): Promise<string | null> {
let userId: string | undefined;
let token: string | undefined;
try {
const verified = await request.jwtVerify<{ userId: string }>();
userId = verified.userId;
const authHeader = request.headers.authorization;
if (authHeader?.startsWith("Bearer ")) {
token = authHeader.substring(7);
}
} catch {
reply.code(401).send({ error: "Authentication required" });
return null;
}
// Check if token was invalidated (user logged out)
if (token && isTokenBlacklisted(token)) {
reply.code(401).send({ error: "Token invalidated" });
return null;
}
return userId || null;
}
server.post("/upload/avatar", async (request, reply) => {
const userId = await authenticateRequest(request, reply);
if (!userId) return reply; // Auth failed, response sent
// ... rest of handler
});
Layer 2: Path Traversal Prevention
Sanitize all user-provided filenames:
// CORRECT: Sanitize filename to prevent path traversal
function sanitizeFilename(filename: string): string {
// Get just the filename (remove any path components)
const baseName = filename.split(/[/\\]/).pop() || filename;
// Replace any potentially dangerous characters
return baseName.replace(/[^a-zA-Z0-9._-]/g, '_');
}
// Usage
const safeFilename = sanitizeFilename(data.filename);
// "../../../etc/passwd" → "etc_passwd"
// "my avatar.jpg" → "my_avatar.jpg"
Better yet, generate your own filenames:
import { randomUUID } from "crypto";
const ext = sanitizeFilename(data.filename).split('.').pop();
const safeFilename = `${randomUUID()}.${ext}`;
// Result: "a1b2c3d4-e5f6-7890-abcd-ef1234567890.jpg"
Layer 3: Magic Byte Validation
Never trust MIME types. Validate file content:
// CORRECT: Check actual file content, not just declared type
const MAGIC_BYTES = {
jpeg: [0xFF, 0xD8, 0xFF],
png: [0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A],
pdf: [0x25, 0x50, 0x44, 0x46], // %PDF
gif: [0x47, 0x49, 0x46, 0x38], // GIF8
zip: [0x50, 0x4B, 0x03, 0x04], // PK
};
function detectFileType(buffer: Buffer): string | null {
// Check JPEG
if (buffer[0] === 0xFF && buffer[1] === 0xD8 && buffer[2] === 0xFF) {
return "image/jpeg";
}
// Check PNG
if (buffer.slice(0, 8).equals(Buffer.from(MAGIC_BYTES.png))) {
return "image/png";
}
// Check PDF
if (buffer.slice(0, 4).toString() === "%PDF") {
return "application/pdf";
}
return null; // Unknown or suspicious file
}
// Usage
const buffer = await data.toBuffer();
const detectedType = detectFileType(buffer);
if (!detectedType || !detectedType.startsWith("image/")) {
return reply.code(400).send({ error: "Invalid file content" });
}
Why this matters:
# Attacker sends executable with fake MIME type
curl -F "file=@malware.exe;type=image/png" ...
# Your MIME check: "image/png" - passes!
# Your magic byte check: Detects executable, blocked!
Layer 4: Multi-Layer Validation
Combine all checks for defense in depth:
// CORRECT: Multi-layer validation
interface FileValidationConfig {
allowedMimeTypes: string[];
allowedExtensions: string[];
maxSizeBytes: number;
}
const FILE_CONFIGS = {
avatar: {
allowedMimeTypes: ["image/jpeg", "image/png", "image/webp"],
allowedExtensions: ["jpg", "jpeg", "png", "webp"],
maxSizeBytes: 5 * 1024 * 1024, // 5MB
},
document: {
allowedMimeTypes: ["application/pdf"],
allowedExtensions: ["pdf"],
maxSizeBytes: 50 * 1024 * 1024, // 50MB
},
};
function validateUploadedFile(
buffer: Buffer,
declaredMimetype: string,
filename: string,
config: FileValidationConfig
): { valid: boolean; error?: string } {
// 1. Validate extension (quick fail)
const ext = filename.split(".").pop()?.toLowerCase();
if (!ext || !config.allowedExtensions.includes(ext)) {
return { valid: false, error: "Invalid file extension" };
}
// 2. Validate declared MIME type (first line of defense)
if (!config.allowedMimeTypes.includes(declaredMimetype)) {
return { valid: false, error: "Invalid file type" };
}
// 3. Validate file size
if (buffer.length > config.maxSizeBytes) {
return { valid: false, error: "File too large" };
}
// 4. Validate magic bytes (CRITICAL - prevents spoofing)
const detectedType = detectFileType(buffer);
if (!detectedType || !config.allowedMimeTypes.includes(detectedType)) {
return { valid: false, error: "File content doesn't match type" };
}
return { valid: true };
}
Layer 5: Safe Error Handling
Hide details in production:
// CORRECT: Environment-aware error handling
function sendErrorResponse(reply, statusCode, message, error) {
// Always log server-side
console.error({ error: error?.message }, message);
// In production, don't expose details
if (process.env.NODE_ENV === "production") {
return reply.code(statusCode).send({ error: message });
} else {
return reply.code(statusCode).send({
error: message,
details: error?.message,
});
}
}
Layer 6: URL Validation (For External Links)
If accepting URLs, validate the protocol:
// CORRECT: Only allow http/https URLs
import { z } from "zod";
const urlSchema = z.string().refine((val) => {
if (!val) return true;
try {
const url = new URL(val);
// Block dangerous protocols
return url.protocol === "http:" || url.protocol === "https:";
} catch {
return false;
}
}, "URL must start with http:// or https://");
// This prevents:
// javascript:alert(document.cookie) - Blocked
// file:///etc/passwd - Blocked
// data:text/html,<script>... - Blocked
// https://example.com/file.pdf - Allowed
Part 4: The Complete Secure Implementation
Here's our final, production-ready upload endpoint:
// PRODUCTION-READY: Secure file upload
import fastify from "fastify";
import multipart from "@fastify/multipart";
import { writeFile, mkdir } from "fs/promises";
import { join } from "path";
import { randomUUID } from "crypto";
// ============================================================================
// CONFIGURATION
// ============================================================================
const UPLOAD_DIR = process.env.UPLOAD_DIR || "./uploads";
const MAX_AVATAR_SIZE = 5 * 1024 * 1024; // 5MB
const ALLOWED_AVATAR_TYPES = ["image/jpeg", "image/png", "image/webp"];
const ALLOWED_AVATAR_EXTENSIONS = ["jpg", "jpeg", "png", "webp"];
const MAGIC_BYTES = {
jpeg: [0xFF, 0xD8, 0xFF],
png: [0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A],
webp: null, // WebP requires RIFF....WEBP check
};
// ============================================================================
// SECURITY UTILITIES
// ============================================================================
function sanitizeFilename(filename: string): string {
const baseName = filename.split(/[/\\]/).pop() || filename;
return baseName.replace(/[^a-zA-Z0-9._-]/g, '_');
}
function detectImageType(buffer: Buffer): string | null {
// JPEG
if (buffer[0] === 0xFF && buffer[1] === 0xD8 && buffer[2] === 0xFF) {
return "image/jpeg";
}
// PNG
if (buffer.slice(0, 8).equals(Buffer.from(MAGIC_BYTES.png))) {
return "image/png";
}
// WebP (RIFF....WEBP)
if (
buffer.length >= 12 &&
buffer.toString("ascii", 0, 4) === "RIFF" &&
buffer.toString("ascii", 8, 12) === "WEBP"
) {
return "image/webp";
}
return null;
}
function isProduction(): boolean {
return process.env.NODE_ENV === "production";
}
async function authenticateRequest(request: any, reply: any): Promise<string | null> {
try {
const verified = await request.jwtVerify<{ userId: string }>();
return verified.userId;
} catch {
reply.code(401).send({ error: "Authentication required" });
return null;
}
}
function sendErrorResponse(reply: any, statusCode: number, message: string, error?: Error) {
console.error({ error: error?.message }, message);
if (isProduction()) {
return reply.code(statusCode).send({ error: message });
}
return reply.code(statusCode).send({ error: message, details: error?.message });
}
// ============================================================================
// VALIDATION
// ============================================================================
function validateAvatar(buffer: Buffer, mimetype: string, filename: string): {
valid: boolean;
error?: string;
} {
// 1. Extension check
const ext = filename.split(".").pop()?.toLowerCase();
if (!ext || !ALLOWED_AVATAR_EXTENSIONS.includes(ext)) {
return { valid: false, error: "Invalid extension. Use: jpg, png, webp" };
}
// 2. Declared MIME type check
if (!ALLOWED_AVATAR_TYPES.includes(mimetype)) {
return { valid: false, error: "Invalid file type" };
}
// 3. Size check
if (buffer.length > MAX_AVATAR_SIZE) {
return { valid: false, error: "File too large (max 5MB)" };
}
// 4. Magic byte validation (CRITICAL)
const detectedType = detectImageType(buffer);
if (!detectedType || !ALLOWED_AVATAR_TYPES.includes(detectedType)) {
return { valid: false, error: "Invalid file content" };
}
return { valid: true };
}
// ============================================================================
// UPLOAD ENDPOINT
// ============================================================================
server.post("/upload/avatar", async (request, reply) => {
// AUTHENTICATION
const userId = await authenticateRequest(request, reply);
if (!userId) return reply;
try {
const data = await request.file();
if (!data) {
return reply.code(400).send({ error: "No file uploaded" });
}
// Load into buffer (consider streaming for large files)
const buffer = await data.toBuffer();
// MULTI-LAYER VALIDATION
const validation = validateAvatar(buffer, data.mimetype, data.filename);
if (!validation.valid) {
return reply.code(400).send({ error: validation.error });
}
// GENERATE SAFE FILENAME
const ext = sanitizeFilename(data.filename).split(".").pop();
const safeFilename = `${randomUUID()}.${ext}`;
// ENSURE DIRECTORY EXISTS
const uploadPath = join(UPLOAD_DIR, "avatars");
await mkdir(uploadPath, { recursive: true });
// WRITE FILE
const filepath = join(uploadPath, safeFilename);
await writeFile(filepath, buffer);
// LOG SUCCESS
console.log({ userId, filename: safeFilename }, "Avatar uploaded");
return reply.send({
success: true,
url: `/uploads/avatars/${safeFilename}`,
});
} catch (error) {
return sendErrorResponse(reply, 500, "Upload failed", error);
}
});
Part 5: Security Checklist
Before deploying any file upload feature, verify:
- Authentication required for all upload endpoints
- Path traversal prevented (sanitize filenames)
- MIME type validated
- Magic bytes validated (actual content check)
- File extension validated
- File size limit enforced
- Own filenames generated (UUID)
- Error details hidden in production
- Uploads logged for audit
- URLs validated (http/https only)
- Virus scanning considered
- Rate limiting applied
Part 6: Additional Recommendations
Rate Limiting
import rateLimit from "@fastify/rate-limit";
server.register(rateLimit, {
max: 10, // 10 uploads per minute
timeWindow: "1 minute",
});
Consider Virus Scanning
For high-security applications, scan uploads with ClamAV:
import { scanFile } from "clamav.js";
const isClean = await scanFile(filepath);
if (!isClean) {
await unlink(filepath);
return reply.code(400).send({ error: "File failed security scan" });
}
Use Object Storage
For production, use S3-compatible storage (Cloudflare R2, AWS S3):
// Upload to cloud storage instead of local filesystem
const publicUrl = await uploadToR2(buffer, filename, "avatars");
Benefits:
- Scalability
- CDN delivery
- Isolation from application server
- Built-in redundancy
Conclusion
Secure file uploads require multiple layers of defense:
- Authentication - Know who's uploading
- Path sanitization - Prevent directory traversal
- MIME validation - First line of defense
- Magic byte validation - The real content check
- Extension validation - Defense in depth
- Size limits - Prevent DoS
- Safe error handling - Don't leak information
Remember: Never trust user input—especially when it comes to filenames and file content!
Found this helpful? Check out our training courses for more security patterns and backend development best practices.
Share this article
Stay Updated
Get the latest AI development tips delivered to your inbox.
