Skip to main content
Back to Blog

The Complete Guide to Secure File Uploads in Node.js

April 8, 2026 4 min read Akonia Codex Team

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:

  1. Authentication - Know who's uploading
  2. Path sanitization - Prevent directory traversal
  3. MIME validation - First line of defense
  4. Magic byte validation - The real content check
  5. Extension validation - Defense in depth
  6. Size limits - Prevent DoS
  7. 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.

#Node.js#security#backend#best practices

Share this article

Stay Updated

Get the latest AI development tips delivered to your inbox.

Related Posts

Swipe →