StackPractices
intermediate By Mathias Paulenko

File Upload Validation: Secure File Handling Guide

How to handle file uploads securely with size, type, and content validation.

Overview

Attackers love file uploads — they’re the easiest way to get code running on your server. A file that slips past validation can mean remote code execution, stored XSS, or a data breach. This recipe walks through validating uploads at five layers — size limits, MIME types, magic bytes, and content structure — before anything from a user touches your filesystem.

When to Use

Reach for this whenever user files cross your trust boundary:

  • An app that accepts user images, documents, or media — for post-upload processing see Image Optimization.
  • A CMS, forum, or SaaS with attachments — for export needs see Export CSV Excel.
  • Anything under PCI-DSS or SOC 2 where uploads are in scope — for credentials see Secret Management.
  • Files coming from untrusted sources like public forms or third-party APIs — for the rest of the input surface see Input Validation.

Skip this when the files never reach your infrastructure — e.g., browser-direct uploads to S3 presigned URLs, where validation moves to the object-store lifecycle rules instead.

Solution

Python

import os
import magic
from werkzeug.utils import secure_filename

ALLOWED_EXTENSIONS = {"png", "jpg", "jpeg", "gif", "pdf"}
MAX_FILE_SIZE = 5 * 1024 * 1024  # 5 MB

def validate_upload(file_storage):
    # 1. Check filename extension
    filename = secure_filename(file_storage.filename)
    ext = filename.rsplit(".", 1)[1].lower() if "." in filename else ""
    if ext not in ALLOWED_EXTENSIONS:
        raise ValueError(f"Extension not allowed: {ext}")

    # 2. Check file size
    file_storage.seek(0, os.SEEK_END)
    size = file_storage.tell()
    file_storage.seek(0)
    if size > MAX_FILE_SIZE:
        raise ValueError(f"File too large: {size} bytes")

    # 3. Check magic bytes (libmagic)
    mime = magic.from_buffer(file_storage.read(2048), mime=True)
    file_storage.seek(0)
    expected_mimes = {
        "png": "image/png", "jpg": "image/jpeg",
        "jpeg": "image/jpeg", "gif": "image/gif", "pdf": "application/pdf"
    }
    if mime != expected_mimes.get(ext):
        raise ValueError(f"MIME mismatch: got {mime}, expected {expected_mimes.get(ext)}")

    return filename

JavaScript (Node.js)

const path = require("path");
const multer = require("multer");
// file-type is ESM-only since v19 — on CommonJS either pin file-type@18
// or load it dynamically inside an async context.
const { fileTypeFromBuffer } = await import("file-type");
const fs = require("fs");

const ALLOWED = { png: "image/png", jpg: "image/jpeg", pdf: "application/pdf" };
const MAX_SIZE = 5 * 1024 * 1024;

const upload = multer({
  limits: { fileSize: MAX_SIZE },
  fileFilter: (req, file, cb) => {
    const ext = path.extname(file.originalname).toLowerCase().replace(".", "");
    if (!ALLOWED[ext]) return cb(new Error("Extension not allowed"));
    cb(null, true);
  },
});

async function validateBuffer(buffer, ext) {
  const type = await fileTypeFromBuffer(buffer);
  if (!type || type.mime !== ALLOWED[ext]) {
    throw new Error(`MIME mismatch: ${type?.mime}`);
  }
  return true;
}

Java (Spring Boot)

import org.springframework.web.multipart.MultipartFile;
import java.util.Set;

public class UploadValidator {
    private static final Set<String> ALLOWED = Set.of("image/png", "image/jpeg", "application/pdf");
    private static final long MAX_SIZE = 5L * 1024 * 1024;

    public static void validate(MultipartFile file) {
        if (file.getSize() > MAX_SIZE) {
            throw new IllegalArgumentException("File exceeds 5 MB");
        }
        String contentType = file.getContentType();
        if (!ALLOWED.contains(contentType)) {
            throw new IllegalArgumentException("MIME type not allowed: " + contentType);
        }
        // Additional: check magic bytes with Apache Tika or similar
    }
}

Explanation

Validation should happen in layers:

  1. Client-side — improves UX but is trivial to bypass.
  2. Server-side extension check — fast but easily spoofed.
  3. Server-side MIME type check — better, but still relies on HTTP headers.
  4. Magic bytes — reads the file’s real signature instead of trusting what the client claims. The strongest single check.
  5. Content scanning / AV — essential for any environment handling untrusted files.

Each layer catches what the others miss — don’t bet on one.

flowchart diagram: Client: extension + size

Variants

TechnologyValidation LibraryNotes
Pythonpython-magicReads libmagic database; very accurate
Node.jsfile-typePure JS, fast, no native deps
JavaApache TikaHeavyweight but handles 1000+ formats
GomimetypeFast, pure Go, zero-allocation reads
RubyMarcelRails default, uses both extension and magic

What Works

  • Validate before saving to disk: check everything in memory or a temp buffer first.
  • Use random filenames: never store files under user-provided names — map them to UUIDs internally.
  • Store outside the web root: let a controller or API serve the files, never the filesystem directly.
  • Scan with AV: integrate ClamAV or a cloud scanner for untrusted uploads.
  • Rate limit uploads: prevents abuse and disk exhaustion.

Per-user upload quotas

Track total uploaded bytes per user so a single account can’t fill the disk. Check quota before running the expensive checks, and consume it only after the file passes validation — otherwise rejected uploads still eat quota:

import redis

r = redis.Redis()

def has_quota(user_id: str, file_size: int, max_quota: int = 100 * 1024 * 1024) -> bool:
    key = f"upload_quota:{user_id}"
    used = int(r.get(key) or 0)
    return used + file_size <= max_quota

def consume_quota(user_id: str, file_size: int) -> None:
    r.incrby(f"upload_quota:{user_id}", file_size)

# if not has_quota(user_id, file_size):
#     raise ValueError("Upload quota exceeded")

Strip EXIF data from uploaded images

EXIF can carry GPS coordinates, camera serial numbers, and other PII. Re-encoding removes most of it — sharp drops metadata by default (only withMetadata() keeps it):

const sharp = require('sharp');

async function stripExif(inputBuffer) {
    // EXIF/ICC metadata is removed unless you call .withMetadata()
    return sharp(inputBuffer)
        .rotate()
        .png()
        .toBuffer();
}

Serve uploads with Content-Disposition: attachment

Prevents browsers from rendering uploaded files inline, which could run scripts in your domain’s context:

location /uploads/ {
    add_header Content-Disposition "attachment";
    add_header X-Content-Type-Options "nosniff";
    add_header Content-Security-Policy "default-src 'none'";
}

Common Mistakes

  • Trusting the Content-Type header: attackers set it to whatever they want.
  • Relying only on extension: a .jpg can contain PHP code.
  • No size limit: one upload can fill your disk.
  • Saving to public directories: drop an executable under the web root and the server may run it for you.
  • No virus scanning: a clean MIME type says nothing about the payload inside.

Ignoring decompression bombs

A small ZIP can expand to gigabytes on extraction. Cap decompressed size while extracting:

import zipfile

MAX_DECOMPRESSED_SIZE = 100 * 1024 * 1024  # 100 MB

def safe_extract_zip(zip_path: str, dest_dir: str) -> int:
    """Extract a ZIP with decompression-bomb protection."""
    total_size = 0
    count = 0
    with zipfile.ZipFile(zip_path, 'r') as zf:
        for info in zf.infolist():
            total_size += info.file_size
            if total_size > MAX_DECOMPRESSED_SIZE:
                raise ValueError(f"Decompression bomb: {total_size} bytes")
            zf.extract(info, dest_dir)
            count += 1
    return count

Allowing SVG uploads without sanitization

SVG can carry <script> tags and onload handlers. Either drop it from the allow-list or sanitize it server-side:

// Option 1: don't accept SVG at all
const ALLOWED = { png: 'image/png', jpg: 'image/jpeg', pdf: 'application/pdf' };

// Option 2: sanitize with DOMPurify (server-side)
// const DOMPurify = require('isomorphic-dompurify');
// const clean = DOMPurify.sanitize(svgString, { USE_PROFILES: { svg: true, svgFilters: true } });

Not logging upload failures

Validation failures are security events. Log them with context for audit and incident response:

import logging

logger = logging.getLogger('upload_security')

def validate_upload_with_logging(file_storage, user_id: str):
    try:
        filename = validate_upload(file_storage)  # the validator from Solution above
        logger.info(f"Upload accepted: user={user_id} file={filename}")
        return filename
    except ValueError as e:
        logger.warning(f"Upload rejected: user={user_id} reason={e} filename={file_storage.filename}")
        raise
    except Exception as e:
        logger.error(f"Upload error: user={user_id} error={e} filename={file_storage.filename}", exc_info=True)
        raise

Further Reading

Frequently Asked Questions

Should I validate on the client or server?

Both. Client-side gives instant feedback; server-side is the only layer that counts for security — never trust anything from the client.

What's the difference between MIME type and magic bytes?

The MIME type comes from the Content-Type header the client sends — trivial to fake. Magic bytes are the signature inside the file's own content — far harder to spoof.

How do I prevent users from uploading malware disguised as images?

Combine magic-byte checks, re-encoding (process the image and re-save it — that strips embedded payloads), and antivirus scanning.

What file types should I never accept?

Anything that can execute: .exe, .bat, .sh, .php, .py, .jar, .war, .jsp, .aspx, plus server config files like .htaccess or .env. Simple rule: if it can execute on your server, it doesn't get in.

How do I handle large uploads (video, datasets)?

Past ~50 MB, switch to chunked resumable uploads: split on the client, validate each chunk, and reassemble on the server. tus, Uppy, and Dropzone.js implement this pattern.

Is this solution production-ready?

Yes — the layered approach (extension + MIME + magic bytes + AV + re-encoding) is what the OWASP File Upload Cheat Sheet recommends. The libraries in Variants are the standard options per ecosystem; bolt on monitoring plus the quota/logging pieces shown above and you're set.

What are the performance characteristics?

The checks run cheapest-first on purpose: extension and MIME are O(1) lookups, magic bytes only reads the first ~2 KB. Re-encoding and AV scanning are the expensive stages — they dominate per-file latency, so run them asynchronously when you can. Stream to temp storage instead of buffering the full upload in memory.

How do I debug upload validation issues?

Check what each tool actually sees: file --mime-type upload.bin for libmagic's verdict, xxd upload.bin | head -5 for the signature (PNG 89504e47, JPEG ffd8ff, PDF 25504446). Then check framework limits — multer's fileSize, Spring's spring.servlet.multipart.max-file-size, or nginx client_max_body_size if you're getting 413s.