Two years ago, someone uploaded a PHP file disguised as a JPEG to one of my client's platforms. Within hours, they had shell access to the server. That incident cost the client thousands in emergency security fixes and nearly destroyed their reputation.

The file passed our "validation" because we only checked the extension. Not the actual file type. Not the content. Just the filename. Classic rookie mistake.

Let me show you how to actually validate file uploads properly so this never happens to you.

Why File Upload Validation Matters

File uploads are one of the most common attack vectors. Here's what can go wrong:

Malicious file execution: Upload a script file, execute it on the server, game over.

Storage exhaustion: Upload gigantic files until the server runs out of space.

XSS attacks: Upload HTML/SVG files with embedded JavaScript.

Path traversal: Upload files with names like ../../../etc/passwd.

Resource exhaustion: Upload files that take forever to process, DoS attack.

The scary part? Most developers only check file extensions. That's not enough. Not even close.

The Three Layers of Validation

Proper file upload validation has three layers:

1. Client-side validation: Fast feedback, better UX, but easily bypassed.

2. Server-side validation: Actually secure, cannot be bypassed.

3. Content validation: Deep inspection of file contents.

Never skip server-side validation. Client-side is nice to have. Content validation depends on your security requirements.

Client-Side: The First Line

Client-side validation gives users immediate feedback. It's not security, but it improves UX.

Basic HTML5 Validation

<input 
  type="file" 
  accept="image/jpeg,image/png,image/gif"
  multiple
  required
>

The accept attribute filters files in the file picker. But it's just a hint. Users can still select any file type.

JavaScript Validation

const fileInput = document.getElementById('fileUpload');
const MAX_FILE_SIZE = 5 * 1024 * 1024; // 5MB
const ALLOWED_TYPES = ['image/jpeg', 'image/png', 'image/gif'];

fileInput.addEventListener('change', (e) => {
  const file = e.target.files[0];
  
  if (!file) return;
  
  // Check file size
  if (file.size > MAX_FILE_SIZE) {
    alert('File too large. Maximum size is 5MB.');
    fileInput.value = '';
    return;
  }
  
  // Check file type
  if (!ALLOWED_TYPES.includes(file.type)) {
    alert('Invalid file type. Only JPEG, PNG, and GIF allowed.');
    fileInput.value = '';
    return;
  }
  
  console.log('File passed validation:', file.name);
});

This checks the MIME type reported by the browser. Better than nothing, but still not secure.

React Implementation

import React, { useState } from 'react';

function FileUpload() {
  const [error, setError] = useState('');
  const [preview, setPreview] = useState(null);

  const MAX_SIZE = 5 * 1024 * 1024;
  const ALLOWED_TYPES = ['image/jpeg', 'image/png', 'image/gif'];

  const validateFile = (file) => {
    if (!file) {
      setError('No file selected');
      return false;
    }

    if (file.size > MAX_SIZE) {
      setError(`File too large. Maximum ${MAX_SIZE / 1024 / 1024}MB`);
      return false;
    }

    if (!ALLOWED_TYPES.includes(file.type)) {
      setError('Invalid file type');
      return false;
    }

    setError('');
    return true;
  };

  const handleFileChange = (e) => {
    const file = e.target.files[0];
    
    if (!validateFile(file)) {
      e.target.value = '';
      setPreview(null);
      return;
    }

    // Create preview
    const reader = new FileReader();
    reader.onload = (e) => setPreview(e.target.result);
    reader.readAsDataURL(file);
  };

  return (
    <div>
      <input 
        type="file" 
        onChange={handleFileChange}
        accept="image/*"
      />
      {error && <p style={{ color: 'red' }}>{error}</p>}
      {preview && <img src={preview} alt="Preview" width="200" />}
    </div>
  );
}

Advanced Client Validation

Check actual file content in the browser:

async function validateImageFile(file) {
  return new Promise((resolve) => {
    const reader = new FileReader();
    
    reader.onload = (e) => {
      const arr = new Uint8Array(e.target.result).subarray(0, 4);
      let header = '';
      
      for (let i = 0; i < arr.length; i++) {
        header += arr[i].toString(16);
      }
      
      // Check magic numbers
      const validHeaders = {
        '89504e47': 'image/png',
        'ffd8ffe0': 'image/jpeg',
        'ffd8ffe1': 'image/jpeg',
        'ffd8ffe2': 'image/jpeg',
        '47494638': 'image/gif'
      };
      
      const fileType = validHeaders[header.toLowerCase()];
      resolve(!!fileType);
    };
    
    reader.readAsArrayBuffer(file);
  });
}

// Usage
fileInput.addEventListener('change', async (e) => {
  const file = e.target.files[0];
  const isValid = await validateImageFile(file);
  
  if (!isValid) {
    alert('Invalid image file');
    fileInput.value = '';
  }
});

This checks file magic numbers (the first few bytes that identify file type). Much better than trusting MIME types.

Server-Side: Where Security Actually Happens

Never trust client-side validation. Always validate on the server.

Node.js/Express Basic Validation

const express = require('express');
const multer = require('multer');
const path = require('path');

const app = express();

// Configure multer
const storage = multer.diskStorage({
  destination: (req, file, cb) => {
    cb(null, 'uploads/');
  },
  filename: (req, file, cb) => {
    const uniqueName = `${Date.now()}-${Math.random().toString(36).substring(7)}`;
    const ext = path.extname(file.originalname);
    cb(null, `${uniqueName}${ext}`);
  }
});

const upload = multer({
  storage: storage,
  limits: {
    fileSize: 5 * 1024 * 1024, // 5MB
    files: 1
  },
  fileFilter: (req, file, cb) => {
    const allowedTypes = ['image/jpeg', 'image/png', 'image/gif'];
    
    if (!allowedTypes.includes(file.mimetype)) {
      return cb(new Error('Invalid file type'), false);
    }
    
    cb(null, true);
  }
});

app.post('/upload', upload.single('file'), (req, res) => {
  if (!req.file) {
    return res.status(400).json({ error: 'No file uploaded' });
  }
  
  res.json({
    message: 'File uploaded successfully',
    filename: req.file.filename,
    size: req.file.size
  });
});

// Error handling
app.use((error, req, res, next) => {
  if (error instanceof multer.MulterError) {
    if (error.code === 'LIMIT_FILE_SIZE') {
      return res.status(400).json({ error: 'File too large' });
    }
  }
  
  res.status(500).json({ error: error.message });
});

This handles size limits and MIME type checking. But MIME types can be spoofed. We need deeper validation.

Content-Based Validation

Use file-type package to check actual file content:

const fileType = require('file-type');
const fs = require('fs').promises;

async function validateFileContent(filePath) {
  const buffer = await fs.readFile(filePath);
  const type = await fileType.fromBuffer(buffer);
  
  if (!type) {
    throw new Error('Could not determine file type');
  }
  
  const allowedTypes = ['image/jpeg', 'image/png', 'image/gif'];
  
  if (!allowedTypes.includes(type.mime)) {
    throw new Error(`Invalid file type: ${type.mime}`);
  }
  
  return type;
}

app.post('/upload', upload.single('file'), async (req, res) => {
  try {
    if (!req.file) {
      return res.status(400).json({ error: 'No file uploaded' });
    }
    
    // Validate actual file content
    const fileInfo = await validateFileContent(req.file.path);
    
    res.json({
      message: 'File uploaded successfully',
      filename: req.file.filename,
      actualType: fileInfo.mime
    });
  } catch (error) {
    // Delete invalid file
    if (req.file) {
      await fs.unlink(req.file.path);
    }
    
    res.status(400).json({ error: error.message });
  }
});

Now we're checking the actual file content, not just the extension or reported MIME type.

Image-Specific Validation

For images, validate dimensions and potentially dangerous content:

const sharp = require('sharp');

async function validateImage(filePath) {
  try {
    const metadata = await sharp(filePath).metadata();
    
    // Check dimensions
    const MAX_WIDTH = 4096;
    const MAX_HEIGHT = 4096;
    
    if (metadata.width > MAX_WIDTH || metadata.height > MAX_HEIGHT) {
      throw new Error('Image dimensions too large');
    }
    
    // Check format
    const allowedFormats = ['jpeg', 'png', 'gif', 'webp'];
    if (!allowedFormats.includes(metadata.format)) {
      throw new Error('Invalid image format');
    }
    
    return metadata;
  } catch (error) {
    throw new Error('Invalid image file');
  }
}

app.post('/upload-image', upload.single('image'), async (req, res) => {
  try {
    const metadata = await validateImage(req.file.path);
    
    // Optionally resize/optimize
    await sharp(req.file.path)
      .resize(1920, 1080, { fit: 'inside', withoutEnlargement: true })
      .jpeg({ quality: 85 })
      .toFile(`${req.file.path}-processed.jpg`);
    
    res.json({
      message: 'Image uploaded',
      width: metadata.width,
      height: metadata.height,
      format: metadata.format
    });
  } catch (error) {
    await fs.unlink(req.file.path);
    res.status(400).json({ error: error.message });
  }
});

PDF Validation

PDFs can contain malicious JavaScript. Here's how to validate them:

const pdf = require('pdf-parse');

async function validatePDF(filePath) {
  const dataBuffer = await fs.readFile(filePath);
  
  try {
    const data = await pdf(dataBuffer);
    
    // Check page count
    if (data.numpages > 100) {
      throw new Error('PDF has too many pages');
    }
    
    // Check file size vs content
    const stats = await fs.stat(filePath);
    if (stats.size > 10 * 1024 * 1024) {
      throw new Error('PDF file too large');
    }
    
    return {
      pages: data.numpages,
      info: data.info
    };
  } catch (error) {
    throw new Error('Invalid or corrupted PDF');
  }
}

Document Validation (Word, Excel, etc.)

const mammoth = require('mammoth');

async function validateDocx(filePath) {
  try {
    const result = await mammoth.extractRawText({ path: filePath });
    const text = result.value;
    
    // Check for suspicious content
    const suspiciousPatterns = [
      /<script/i,
      /javascript:/i,
      /on\w+\s*=/i // onerror, onclick, etc.
    ];
    
    for (const pattern of suspiciousPatterns) {
      if (pattern.test(text)) {
        throw new Error('Document contains suspicious content');
      }
    }
    
    return { textLength: text.length };
  } catch (error) {
    throw new Error('Invalid Word document');
  }
}

Comprehensive Validation Class

Here's a complete file validator:

const fileType = require('file-type');
const sharp = require('sharp');
const fs = require('fs').promises;

class FileValidator {
  constructor(options = {}) {
    this.maxSize = options.maxSize || 5 * 1024 * 1024;
    this.allowedTypes = options.allowedTypes || ['image/jpeg', 'image/png'];
    this.maxDimensions = options.maxDimensions || { width: 4096, height: 4096 };
  }

  async validateSize(filePath) {
    const stats = await fs.stat(filePath);
    
    if (stats.size > this.maxSize) {
      throw new Error(`File size ${stats.size} exceeds maximum ${this.maxSize}`);
    }
    
    return stats.size;
  }

  async validateType(filePath) {
    const buffer = await fs.readFile(filePath);
    const type = await fileType.fromBuffer(buffer);
    
    if (!type) {
      throw new Error('Could not determine file type');
    }
    
    if (!this.allowedTypes.includes(type.mime)) {
      throw new Error(`File type ${type.mime} not allowed`);
    }
    
    return type;
  }

  async validateImage(filePath) {
    try {
      const metadata = await sharp(filePath).metadata();
      
      if (metadata.width > this.maxDimensions.width) {
        throw new Error(`Image width ${metadata.width} exceeds maximum`);
      }
      
      if (metadata.height > this.maxDimensions.height) {
        throw new Error(`Image height ${metadata.height} exceeds maximum`);
      }
      
      return metadata;
    } catch (error) {
      throw new Error('Invalid image file');
    }
  }

  sanitizeFilename(filename) {
    return filename
      .replace(/[^a-zA-Z0-9.-]/g, '_')
      .replace(/\.{2,}/g, '.')
      .substring(0, 255);
  }

  async validate(filePath, originalFilename) {
    const errors = [];
    const results = {};

    try {
      results.size = await this.validateSize(filePath);
    } catch (error) {
      errors.push(error.message);
    }

    try {
      results.type = await this.validateType(filePath);
    } catch (error) {
      errors.push(error.message);
    }

    if (results.type && results.type.mime.startsWith('image/')) {
      try {
        results.metadata = await this.validateImage(filePath);
      } catch (error) {
        errors.push(error.message);
      }
    }

    results.safeFilename = this.sanitizeFilename(originalFilename);

    if (errors.length > 0) {
      throw new Error(errors.join('; '));
    }

    return results;
  }
}

// Usage
const validator = new FileValidator({
  maxSize: 5 * 1024 * 1024,
  allowedTypes: ['image/jpeg', 'image/png', 'image/gif'],
  maxDimensions: { width: 2048, height: 2048 }
});

app.post('/upload', upload.single('file'), async (req, res) => {
  try {
    const results = await validator.validate(
      req.file.path,
      req.file.originalname
    );
    
    res.json({
      message: 'File validated successfully',
      ...results
    });
  } catch (error) {
    await fs.unlink(req.file.path);
    res.status(400).json({ error: error.message });
  }
});

Handling Multiple Files

const uploadMultiple = multer({
  storage: storage,
  limits: {
    fileSize: 5 * 1024 * 1024,
    files: 10
  }
}).array('files', 10);

app.post('/upload-multiple', (req, res) => {
  uploadMultiple(req, res, async (err) => {
    if (err) {
      return res.status(400).json({ error: err.message });
    }
    
    const results = [];
    const errors = [];
    
    for (const file of req.files) {
      try {
        const validation = await validator.validate(
          file.path,
          file.originalname
        );
        
        results.push({
          filename: file.filename,
          ...validation
        });
      } catch (error) {
        errors.push({
          filename: file.originalname,
          error: error.message
        });
        
        await fs.unlink(file.path);
      }
    }
    
    res.json({
      uploaded: results.length,
      failed: errors.length,
      results,
      errors
    });
  });
});

Virus Scanning

For production apps handling user uploads, scan for viruses:

const NodeClam = require('clamscan');

const clamscan = new NodeClam().init({
  clamdscan: {
    host: 'localhost',
    port: 3310
  }
});

async function scanFile(filePath) {
  const { isInfected, viruses } = await clamscan.isInfected(filePath);
  
  if (isInfected) {
    throw new Error(`Virus detected: ${viruses.join(', ')}`);
  }
  
  return true;
}

app.post('/upload', upload.single('file'), async (req, res) => {
  try {
    await scanFile(req.file.path);
    
    res.json({ message: 'File is clean' });
  } catch (error) {
    await fs.unlink(req.file.path);
    res.status(400).json({ error: error.message });
  }
});

Rate Limiting Uploads

Prevent abuse with rate limiting:

const rateLimit = require('express-rate-limit');

const uploadLimiter = rateLimit({
  windowMs: 15 * 60 * 1000, // 15 minutes
  max: 10, // 10 uploads per window
  message: 'Too many uploads, please try again later'
});

app.post('/upload', uploadLimiter, upload.single('file'), async (req, res) => {
  // Handle upload
});

Storage Best Practices

Never store uploads in the webroot:

// Bad
const uploadDir = path.join(__dirname, 'public', 'uploads');

// Good
const uploadDir = path.join(__dirname, '..', 'private', 'uploads');

Use a separate domain or CDN:

// Serve files through a controller
app.get('/files/:id', async (req, res) => {
  // Check permissions
  const file = await getFileById(req.params.id);
  
  if (!userCanAccessFile(req.user, file)) {
    return res.status(403).json({ error: 'Access denied' });
  }
  
  res.sendFile(file.path);
});

Handling Archive Files

ZIP files are tricky. They can contain path traversal attacks:

const AdmZip = require('adm-zip');
const path = require('path');

async function validateZip(filePath) {
  const zip = new AdmZip(filePath);
  const entries = zip.getEntries();
  
  for (const entry of entries) {
    // Check for path traversal
    const normalized = path.normalize(entry.entryName);
    if (normalized.startsWith('..') || path.isAbsolute(normalized)) {
      throw new Error('Archive contains invalid paths');
    }
    
    // Check individual file sizes
    if (entry.header.size > 10 * 1024 * 1024) {
      throw new Error('Archive contains files that are too large');
    }
  }
  
  // Check total uncompressed size
  const totalSize = entries.reduce((sum, e) => sum + e.header.size, 0);
  if (totalSize > 50 * 1024 * 1024) {
    throw new Error('Archive uncompressed size too large');
  }
  
  return entries.length;
}

Real-World Complete Example

Here's everything together in a production-ready implementation:

const express = require('express');
const multer = require('multer');
const fileType = require('file-type');
const sharp = require('sharp');
const rateLimit = require('express-rate-limit');
const { v4: uuidv4 } = require('uuid');
const path = require('path');
const fs = require('fs').promises;

const app = express();

// Configuration
const UPLOAD_DIR = path.join(__dirname, '..', 'uploads');
const MAX_FILE_SIZE = 5 * 1024 * 1024;
const ALLOWED_IMAGE_TYPES = ['image/jpeg', 'image/png', 'image/gif', 'image/webp'];

// Ensure upload directory exists
fs.mkdir(UPLOAD_DIR, { recursive: true });

// Storage configuration
const storage = multer.diskStorage({
  destination: (req, file, cb) => {
    cb(null, UPLOAD_DIR);
  },
  filename: (req, file, cb) => {
    const id = uuidv4();
    const ext = path.extname(file.originalname);
    cb(null, `${id}${ext}`);
  }
});

// Upload middleware
const upload = multer({
  storage,
  limits: { fileSize: MAX_FILE_SIZE }
});

// Rate limiter
const uploadLimiter = rateLimit({
  windowMs: 15 * 60 * 1000,
  max: 20,
  message: 'Too many uploads'
});

// Validation function
async function validateUpload(file) {
  // Check file size
  const stats = await fs.stat(file.path);
  if (stats.size > MAX_FILE_SIZE) {
    throw new Error('File too large');
  }

  // Check actual file type
  const buffer = await fs.readFile(file.path);
  const type = await fileType.fromBuffer(buffer);
  
  if (!type || !ALLOWED_IMAGE_TYPES.includes(type.mime)) {
    throw new Error('Invalid file type');
  }

  // Validate image
  const metadata = await sharp(file.path).metadata();
  
  if (metadata.width > 4096 || metadata.height > 4096) {
    throw new Error('Image dimensions too large');
  }

  // Process image (remove EXIF, resize if needed)
  await sharp(file.path)
    .resize(2048, 2048, { fit: 'inside', withoutEnlargement: true })
    .jpeg({ quality: 85 })
    .toFile(`${file.path}.processed`);

  // Replace original with processed
  await fs.unlink(file.path);
  await fs.rename(`${file.path}.processed`, file.path);

  return {
    type: type.mime,
    size: stats.size,
    width: metadata.width,
    height: metadata.height
  };
}

// Upload endpoint
app.post('/api/upload', 
  uploadLimiter,
  upload.single('file'),
  async (req, res) => {
    try {
      if (!req.file) {
        return res.status(400).json({ error: 'No file provided' });
      }

      const validation = await validateUpload(req.file);

      res.json({
        success: true,
        fileId: path.parse(req.file.filename).name,
        ...validation
      });
    } catch (error) {
      if (req.file) {
        await fs.unlink(req.file.path).catch(() => {});
      }
      
      res.status(400).json({
        success: false,
        error: error.message
      });
    }
  }
);

// Error handling
app.use((error, req, res, next) => {
  if (error instanceof multer.MulterError) {
    return res.status(400).json({ error: error.message });
  }
  
  res.status(500).json({ error: 'Internal server error' });
});

app.listen(3000, () => {
  console.log('Server running on port 3000');
});

Testing File Validation

Write tests for your validation logic:

const request = require('supertest');
const fs = require('fs');
const path = require('path');

describe('File Upload', () => {
  test('should accept valid image', async () => {
    const response = await request(app)
      .post('/api/upload')
      .attach('file', path.join(__dirname, 'test-image.jpg'));

    expect(response.status).toBe(200);
    expect(response.body.success).toBe(true);
  });


  test('should reject oversized file', async () => {
    // Create large file
    const largeFile = Buffer.alloc(10 * 1024 * 1024);
    fs.writeFileSync('/tmp/large.jpg', largeFile);

    const response = await request(app)
      .post('/api/upload')
      .attach('file', '/tmp/large.jpg');

    expect(response.status).toBe(400);
  });

  test('should reject invalid file type', async () => {
    const response = await request(app)
      .post('/api/upload')
      .attach('file', path.join(__dirname, 'test.txt'));

    expect(response.status).toBe(400);
  });
});

If you're building a file-sharing platform or a SaaS product with file upload features and need to handle payments or subscriptions, consider integrating Dodo Payments for your billing infrastructure.

Common Security Mistakes

1. Trusting file extensions: Always check actual content.

2. Not sanitizing filenames: Path traversal attacks are real.

3. Storing in webroot: Files should be outside public access.

4. No size limits: Leads to storage exhaustion.

5. Missing rate limiting: Easy DoS vector.

6. Not scanning for viruses: Especially for public apps.

7. Allowing execution: Never allow .php, .exe, .sh, etc.

Production Checklist

Before going live with file uploads:

  • βœ… Validate file size
  • βœ… Check actual file type (magic numbers)
  • βœ… Sanitize filenames
  • βœ… Store outside webroot
  • βœ… Implement rate limiting
  • βœ… Scan for viruses
  • βœ… Set appropriate permissions
  • βœ… Log all uploads
  • βœ… Monitor storage usage
  • βœ… Have backup/cleanup strategy

When to Use Cloud Storage

For production apps, consider cloud storage (S3, Google Cloud Storage):

const AWS = require('aws-sdk');
const s3 = new AWS.S3();

async function uploadToS3(file) {
  const fileContent = await fs.readFile(file.path);
  
  const params = {
    Bucket: process.env.S3_BUCKET,
    Key: file.filename,
    Body: fileContent,
    ContentType: file.mimetype,
    ACL: 'private'
  };
  
  const result = await s3.upload(params).promise();
  
  return result.Location;
}

Benefits: scalability, CDN integration, no disk management, better security.

The Bottom Line

File upload validation is not optional. It's security critical. Every production app that accepts file uploads needs proper validation.

Client-side is nice. Server-side is mandatory. Content validation is where real security happens.

Check file size. Check actual file type. Validate content. Sanitize filenames. Store safely. Rate limit. Scan for viruses. Test everything.

The PHP shell incident I mentioned at the start? Could have been prevented with proper content validation. Don't learn this lesson the hard way.

What's the worst file upload security issue you've encountered? The horror stories in this space are endless.