Secure File Uploads in PHP

1. The Problem With Simple Upload Code

Here's the code most beginners start with:

// ⚠️  Do NOT use this in production
if ($_FILES['file']['error'] === UPLOAD_ERR_OK) {
    move_uploaded_file(
        $_FILES['file']['tmp_name'],
        'uploads/' . $_FILES['file']['name']
    );
    echo 'Uploaded!';
}

This code has at least five distinct vulnerabilities:

2. Never Trust the Client's MIME Type

$_FILES['file']['type'] is supplied by the browser. A curl request or a crafted form can set it to anything — image/jpeg, text/plain, whatever you're expecting. It is data from user input, full stop. Treat it accordingly: use it for nothing security-critical.

⚠ Warning $_FILES['file']['type'] is user-supplied and completely untrustworthy. Never make an accept/reject decision based on it alone.

Instead, detect the MIME type server-side, from the actual bytes of the uploaded file. PHP's finfo extension does exactly this:

$finfo = new finfo(FILEINFO_MIME_TYPE);
$detectedMime = $finfo->file($_FILES['file']['tmp_name']);

$allowedMimes = [
    'image/jpeg',
    'image/png',
    'image/webp',
    'application/pdf',
];

if (!in_array($detectedMime, $allowedMimes, true)) {
    throw new RuntimeException('File type not permitted.');
}

finfo reads the file's magic bytes — the first few bytes that identify a file format. A JPEG always starts with FF D8 FF; a PNG with 89 50 4E 47. This is far harder to spoof than a filename or Content-Type header.

3. Validate the Extension — Correctly

Extension validation matters too, but it's a separate concern from MIME detection. An attacker who can't fool finfo might try to sneak past a sloppy extension check. The key mistake people make is checking against pathinfo($name, PATHINFO_EXTENSION) and forgetting that PHP considers the last dot extension — so shell.php.jpg has extension jpg, which passes. But some older server configs or misconfigured NGINX rules may execute it anyway.

The correct approach: allowlist, not denylist, and also check that there's only one extension doing meaningful work.

function isSafeExtension(string $originalName): bool
{
    $allowed = ['jpg', 'jpeg', 'png', 'webp', 'pdf'];

    // Grab just the final extension, lowercased
    $ext = strtolower(pathinfo($originalName, PATHINFO_EXTENSION));

    if (!in_array($ext, $allowed, true)) {
        return false;
    }

    // Reject files with multiple extensions like shell.php.jpg
    $basename = pathinfo($originalName, PATHINFO_FILENAME);
    if (str_contains($basename, '.')) {
        return false;
    }

    return true;
}

4. Check the Real File Content

Even with MIME detection in place, a determined attacker can embed PHP code inside a valid JPEG. This is called a polyglot file — a file that is simultaneously two things: a valid image and a PHP script. If the server ever executes it, the embedded payload runs.

For images, the best defense is to re-encode them through GD or Imagick. A re-encoded image loses any embedded payloads because the encoder only writes pixel data and metadata it understands:

function sanitizeImage(string $tmpPath, string $mime): string
{
    $img = match($mime) {
        'image/jpeg' => imagecreatefromjpeg($tmpPath),
        'image/png'  => imagecreatefrompng($tmpPath),
        'image/webp' => imagecreatefromwebp($tmpPath),
        default      => throw new RuntimeException('Unsupported image type'),
    };

    if ($img === false) {
        throw new RuntimeException('Invalid image data.');
    }

    $clean = tempnam(sys_get_temp_dir(), 'img_');

    match($mime) {
        'image/jpeg' => imagejpeg($img, $clean, 90),
        'image/png'  => imagepng($img, $clean),
        'image/webp' => imagewebp($img, $clean),
    };

    imagedestroy($img);

    return $clean; // path to the sanitized temp file
}
✦ Tip For PDFs and other document types you can't re-encode, consider scanning with ClamAV via PHP's exec(), or routing uploads through a dedicated scanning service before accepting them.

5. Enforce Size Limits in PHP, Not Just HTML

The accept and size attributes on an HTML <input type="file"> are UX helpers, not security controls. Anyone can remove them with browser DevTools or send a raw HTTP request. Your PHP code must validate size independently.

const MAX_BYTES = 5 * 1024 * 1024; // 5 MB

// Check the upload error code first — oversized files trigger UPLOAD_ERR_INI_SIZE
$error = $_FILES['file']['error'];

if ($error === UPLOAD_ERR_INI_SIZE || $error === UPLOAD_ERR_FORM_SIZE) {
    throw new RuntimeException('File exceeds the maximum allowed size.');
}

if ($error !== UPLOAD_ERR_OK) {
    throw new RuntimeException('Upload failed with error code: ' . $error);
}

// Then double-check the actual file size from disk
if ($_FILES['file']['size'] > MAX_BYTES) {
    throw new RuntimeException('File too large.');
}

// Cross-check with the actual tmp file size (cannot be spoofed)
if (filesize($_FILES['file']['tmp_name']) > MAX_BYTES) {
    throw new RuntimeException('File too large.');
}

Also set upload_max_filesize and post_max_size conservatively in your php.ini or per-virtualhost config. PHP-level validation and php.ini limits are complementary — neither replaces the other.

6. Rename Everything on Arrival

The original filename from the client should never be used as the stored filename. Even if you sanitize it, you risk collisions, Unicode shenanigans, and filenames that are meaningful to attackers. Generate a new name server-side:

function generateStorageName(string $mime): string
{
    // Map MIME types to safe extensions
    $extMap = [
        'image/jpeg'      => 'jpg',
        'image/png'       => 'png',
        'image/webp'      => 'webp',
        'application/pdf' => 'pdf',
    ];

    $ext = $extMap[$mime] ?? throw new RuntimeException('Unknown MIME type');

    // Cryptographically random 32-byte hex string
    return bin2hex(random_bytes(16)) . '.' . $ext;
}

// Example: "3f8a21c9b7e042d1a6c8f5d3e0b14a97.jpg"

A random hex name has two benefits: it's unguessable (so users can't enumerate files), and it can never be a path traversal string or an executable filename that a server would run by mistake.

7. Store Files Outside the Web Root

If your web root is /var/www/html, store uploads in /var/www/storage/uploads — a directory the web server cannot serve directly. This is the single most important architectural decision for upload security.

Even if every validation step above somehow failed and a PHP file landed on disk, it cannot be executed by the web server if it's outside the document root. No path to it via HTTP means no execution vector.

// ✓ Outside the web root — cannot be directly requested
define('UPLOAD_DIR', '/var/www/storage/uploads/');

// ✗ Inside the web root — directly accessible at /uploads/filename
// define('UPLOAD_DIR', '/var/www/html/uploads/');

move_uploaded_file(
    $cleanTmpPath,
    UPLOAD_DIR . $storageName
);

Make sure the storage directory is writable by the PHP process (www-data on most Linux distros) but not readable by the web server directly. Set permissions to 750 or 700.

8. Serve Files Through a PHP Controller

Because files are stored outside the web root, you need a PHP script to deliver them. This is actually an advantage — you can apply authorization checks before serving any file:

// download.php  —  a minimal, safe file-serving controller

$requestedId = (int) ($_GET['id'] ?? 0);

// 1. Authenticate and authorize the user
if (!currentUserCanAccessFile($requestedId)) {
    http_response_code(403);
    exit('Access denied.');
}

// 2. Look up the stored filename from your database (never from GET params)
$file = getFileRecordById($requestedId);

if ($file === null) {
    http_response_code(404);
    exit('Not found.');
}

$fullPath = UPLOAD_DIR . $file['storage_name'];

// 3. Validate the resolved path is inside the expected directory
if (!str_starts_with(realpath($fullPath), realpath(UPLOAD_DIR))) {
    http_response_code(400);
    exit('Invalid path.');
}

// 4. Send the file with appropriate headers
header('Content-Type: ' . $file['mime_type']);
header('Content-Length: ' . filesize($fullPath));
header('Content-Disposition: inline; filename="' . basename($file['original_name']) . '"');
header('X-Content-Type-Options: nosniff');
header('Content-Security-Policy: default-src \'none\'');

readfile($fullPath);

Note the X-Content-Type-Options: nosniff header — this prevents browsers from sniffing the content and treating a file as something other than its declared MIME type. The strict Content-Security-Policy prevents any scripts inside an HTML or SVG file from executing in the context of your domain.

9. Extra Steps for Image Uploads

Images deserve special attention because they're ubiquitous and the attack surface is well-studied. Beyond the re-encoding already covered, consider:

Strip EXIF Data

EXIF metadata embedded in JPEGs can contain GPS coordinates, device identifiers, and other information your users may not intend to share. Imagick makes stripping it trivial:

$img = new Imagick($cleanTmpPath);
$img->stripImage(); // removes all EXIF, IPTC, XMP metadata
$img->writeImage($destination);

Validate Image Dimensions

A 1×1 pixel file claiming to be a 100,000×100,000 pixel image can trigger massive memory allocations when decoded — a classic decompression bomb. Check before loading:

$info = getimagesize($_FILES['file']['tmp_name']);

if ($info === false) {
    throw new RuntimeException('Not a valid image.');
}

[$width, $height] = $info;

if ($width > 8000 || $height > 8000) {
    throw new RuntimeException('Image dimensions too large.');
}
✦ Tip Consider processing image uploads asynchronously in a queue worker rather than in the HTTP request. This protects your web server from memory spikes and makes decompression-bomb attacks much less effective.