File Upload Security and MIME-Type Validation

Secure PHP uploads with error and size checks, server MIME detection, generated names, private storage, and controlled downloads.

Lesson 58 of 86Intermediate12 min read

An upload lets a visitor provide bytes that your server will keep. Those bytes may be large, malformed, executable, or disguised as another format. The name and MIME type reported by the browser are claims from the visitor, not evidence. A safe PHP handler checks request shape, upload error, size, detected content type, and destination before saving anything. This reference builds a restricted JPEG-and-PNG handler and explains why MIME detection is one layer of a policy. You also need storage that cannot execute the uploaded content, authorization for private downloads, and resource limits for processing files.

The upload lifecycle and request limits

A file form needs method="post" and enctype="multipart/form-data". PHP describes each parsed upload in $_FILES with name, tmp_name, error, size, and a browser-supplied type. move_uploaded_file() moves a recognized PHP upload from its temporary location to your chosen destination. Validate the shape of the supported single-file field; multiple-file forms can produce nested arrays. UPLOAD_ERR_OK is the success code. Other codes distinguish no selection, interruption, and server storage failures. If the entire request exceeds post_max_size, expected POST and FILES entries may be absent. Web servers and proxies also have body limits. Explain limits to users before submission, then enforce them again on the server. The HTML accept attribute only improves the picker; a custom client can ignore it completely.

Enforce a small content policy

Decide which formats your feature needs rather than accepting every type PHP can identify. A profile picture usually needs a small set of bitmap formats. Check upload errors first, then nonzero size and an application maximum. upload_max_filesize and post_max_size are additional boundaries, with the complete request needing room for multipart overhead. Avoid reading a huge file into memory to discover it is too large. Compressed images can expand into enormous pixel buffers, so decoding and thumbnail creation need dimension and memory limits. Use maintained processing libraries and limit total files, user storage, and upload frequency. An otherwise valid image can still fill the disk if uploads are unlimited. Keep error responses specific enough to help without revealing storage paths.

Validate MIME type using the temporary bytes

Use finfo with FILEINFO_MIME_TYPE on the temporary file, then compare its result with an allowlist mapping MIME types to application-chosen extensions. Neither $_FILES['type'] nor the original extension proves the actual format. Fileinfo uses signatures and classification rules; it is not a complete malware scanner or a guarantee that every parser can safely process the file. A stricter bitmap pipeline decodes and re-encodes the image under resource limits. SVG is active XML content with a different risk profile, so this example does not accept it. Select the response Content-Type from validated metadata when serving files. Avoid guessing types from names during downloads, particularly when files may be interpreted by a browser.

Store files away from execution

Choose a private directory outside the public document root and ensure the server cannot execute scripts there. Generate a filename using random_bytes() and an extension selected from your policy. This avoids traversal, attacker-chosen replacement names, and accidental collisions. Random names do not grant authorization: private downloads still require ownership or role checks. Store a generated name, owner, type, size, and internal identifier in a database. A controlled endpoint retrieves metadata, checks access, and reads the trusted path. The storage directory should be controlled by the application rather than writable by unrelated users. Check move_uploaded_file() before recording success. If metadata saving fails after the move, remove the specific file or track it for cleanup.

Prevent secondary upload attacks

Escape original filenames when showing them in HTML; a filename can contain markup. Never use the original name as a filesystem path. A name such as portrait.jpg.php becomes especially dangerous in an executable public folder. Session-authenticated upload endpoints need CSRF protection, and access should match the account feature's rules. Documents may need malware scanning with availability delayed until checks finish. Audit the owner, time, size, type, and scan result without logging confidential contents. Download responses often use Content-Disposition: attachment and X-Content-Type-Options: nosniff. Deletion should take an internal file ID and use the stored generated name, rather than accepting a path. Treat missing files and abandoned metadata as recoverable cases with a cleanup process.

Test acceptance and rejection deliberately

Prepare one small JPEG, one PNG, an empty file, an oversized file, and plain text renamed to look like an image. Check each against your declared policy and verify rejected uploads never become available. Send a malformed array-shaped field to test request-shape handling. In an isolated environment, make storage unwritable and confirm the response reports failure rather than successful upload. Verify another user's file cannot be downloaded or deleted, even with its internal ID. Also test request limits at the server and proxy layers because your handler may not run for an oversized body. Repeat processing tests with unusual dimensions rather than treating file size alone as a complete resource budget.

Practice task and worked solution

Extend the design for private receipts. Decide your permitted formats, save internal ID and owner alongside the generated filename, and create an authorized download endpoint. The solution accepts a file ID, queries metadata using a prepared statement, and checks the authenticated owner before streaming. Set Content-Type from validated metadata and choose an attachment disposition. Test a missing ID, another user's ID, an unsupported format, and metadata whose stored file disappeared. If scanning is part of the policy, keep files unavailable while their state is pending. Never trust the original upload name when reconstructing the storage path.

Worked authorized download

Assume receipts has id, owner_id, storage_name, mime_type, and scan_state columns. The authenticated owner ID comes from trusted session state, and the storage name was generated by the upload handler. The same private directory is used for saving and reading. Only a clean file becomes available. This route needs GET-method handling under the application's router; it performs no state change.

PHP
<?php
// $receiptId is a validated integer; $ownerId is trusted authentication state.
$stmt = $pdo->prepare(
    'SELECT storage_name, mime_type FROM receipts '
    . "WHERE id = ? AND owner_id = ? AND scan_state = 'clean'"
);
$stmt->execute([$receiptId, $ownerId]);
$row = $stmt->fetch(PDO::FETCH_ASSOC);
$types = ['image/jpeg', 'image/png', 'application/pdf'];
if ($row === false
    || !preg_match('/\A[a-f0-9]{32}\.(?:jpg|png|pdf)\z/', $row['storage_name'])
    || !in_array($row['mime_type'], $types, true)) {
    http_response_code(404); exit('Receipt unavailable.');
}
$path = dirname(__DIR__) . '/private-upload/' . $row['storage_name'];
$handle = fopen($path, 'rb');
if ($handle === false) {
    http_response_code(404); exit('Receipt unavailable.');
}
try {
    header('Content-Type: ' . $row['mime_type']);
    header('Content-Disposition: attachment; filename="receipt"');
    header('X-Content-Type-Options: nosniff');
    header('Cache-Control: no-store');
    fpassthru($handle);
} finally {
    fclose($handle);
}

Expected behavior: an authorized clean receipt streams as an attachment; another owner's receipt, a pending scan, and a missing record receive the same unavailable response. The private directory must be application-controlled. Configure warnings to log privately so a failed open cannot disclose its filesystem path. The download policy includes PDF as an extension of the task; the earlier bitmap upload handler still accepts only JPEG and PNG.

Continue learning

Read form validation, file handling, and security for the surrounding request and storage boundaries.

The PHP upload manual documents upload metadata and error codes.

File Upload Security and MIME-Type Validation Examples

Each example shows the code, the output it produces and what each part does.

Example 1

Store a permitted bitmap privately

This PHP server handler assumes authentication and CSRF verification already succeeded. The private-upload directory exists outside the public root.

A successful allowed upload returns Image stored. Its random filename differs each time. Oversized files receive 413 and unsupported types receive 415. This needs a PHP-enabled server and Fileinfo; PHP does not run inside the browser.

PHP
<?php
$file = $_FILES['photo'] ?? null;
if (!is_array($file) || !isset($file['error'], $file['tmp_name'], $file['size'])
    || !is_int($file['error']) || !is_string($file['tmp_name'])
    || !is_int($file['size']) || $file['error'] !== UPLOAD_ERR_OK) {
    http_response_code(400); exit('Upload was not accepted.');
}
if ($file['size'] < 1 || $file['size'] > 2 * 1024 * 1024) {
    http_response_code(413); exit('Choose an image up to 2 MiB.');
}
$type = (new finfo(FILEINFO_MIME_TYPE))->file($file['tmp_name']);
$allowed = ['image/jpeg' => 'jpg', 'image/png' => 'png'];
if (!is_string($type) || !isset($allowed[$type])) {
    http_response_code(415); exit('Choose a JPEG or PNG.');
}
$name = bin2hex(random_bytes(16)) . '.' . $allowed[$type];
$destination = dirname(__DIR__) . '/private-upload/' . $name;
if (!move_uploaded_file($file['tmp_name'], $destination)) {
    http_response_code(500); exit('Unable to store image.');
}
echo 'Image stored.';
Output
Successful accepted upload: Image stored.

File Upload Security and MIME-Type Validation FAQ

Is the browser MIME type reliable?

No. The type value in $_FILES is supplied by the browser, so anyone can send image/png with a PHP script attached. Inspect the actual bytes on the server, for example with finfo_file(), and check the result against an allowlist of types you accept.

Are random names sufficient?

They solve naming problems, such as overwriting another file or path tricks in the original name, but not everything. You still need authorization to decide who may upload and download, size limits, type checks, and storage that the web server will never execute as code, ideally outside the public folder.

Why is $_FILES empty?

The request probably exceeded a server or PHP limit, such as post_max_size, upload_max_filesize or the web server's own body limit; PHP then discards the upload. Also check that the form uses method="post" and enctype="multipart/form-data", and inspect the error code for each file.

Can I accept SVG like PNG?

No. SVG is XML that can contain scripts, external references and event handlers, so it carries active-content concerns that a PNG doesn't. It needs a separate, deliberate policy, such as serving it as a download, sanitizing it with a dedicated library, or converting it to a raster image.

File Upload Security and MIME-Type Validation Exercises

Answer each one and check it instantly. If you get stuck, open the hint before the answer.

Multiple choicemedium

A file named avatar.png has an unsupported detected type. What happens?

File Upload Security and MIME-Type Validation Quiz

2 questions to check what you learned.

  1. Which type controls an accepted extension?

    • A server-detected MIME type in the allowlist
    • The original filename
    • The browser type alone
    • A query parameter
  2. What protects private downloads?

    • A random name alone
    • An authorization check before reading the stored file
    • A .jpg extension
    • The accept form attribute