Skip to content

Secure File Uploads in PHP: A Checklist

An upload form is the most direct way to hand an attacker a file on your server. Get it wrong and the outcome is not a corrupted image — it is remote code execution. This is the checklist, ordered by how much damage each item prevents.

1. Never trust the reported MIME type

The value in $_FILES['file']['type'] comes from the browser. It is part of the request, so anyone can set it to whatever passes your check:

// Trivially bypassed - the client chooses this value
if ($_FILES['file']['type'] === 'image/jpeg') { /* ... */ }

Inspect the actual bytes instead, with fileinfo:

$finfo = new finfo(FILEINFO_MIME_TYPE);
$real  = $finfo->file($_FILES['file']['tmp_name']);   // e.g. 'image/jpeg'

$allowed = ['image/jpeg' => 'jpg', 'image/png' => 'png', 'application/pdf' => 'pdf'];
if (!isset($allowed[$real])) {
    throw new RuntimeException('File type not permitted.');
}

Detection reads magic bytes, so it is far stronger than the header — but it is not proof of safety on its own. A polyglot file can carry a valid JPEG header and PHP code further in. Content detection plus the rest of this list is what makes it safe.

2. Use an allow-list, never a deny-list

Blocking .php feels like the obvious defence and it fails immediately. Depending on server configuration, all of these can still execute: .php3, .php4, .php5, .php7, .phtml, .phar, .inc. Add .htaccess (which can turn any extension into PHP on Apache) and case tricks like .PhP, and the deny-list is unwinnable.

$ext = strtolower(pathinfo($_FILES['file']['name'], PATHINFO_EXTENSION));
if (!in_array($ext, ['jpg', 'jpeg', 'png', 'gif', 'pdf'], true)) {
    throw new RuntimeException('File type not permitted.');
}
// and require that the extension agrees with the detected content type

3. Generate the stored filename

Never build a path from the user's filename. It brings path traversal (../../config.php), null-byte tricks on old versions, overwrites of existing files, unicode look-alikes, and names that break on other filesystems.

$safeName = bin2hex(random_bytes(16)) . '.' . $ext;
$target   = $uploadDir . '/' . $safeName;

Keep the original name in your database for display, and escape it on output — a filename like <img src=x onerror=alert(1)>.jpg is a stored XSS payload if you echo it raw.

4. Store uploads outside the web root

If a file cannot be requested by URL, an uploaded script cannot be executed by URL. This single measure defeats most upload attacks:

/var/www/
    html/           <- document root
        upload.php
    uploads/        <- not reachable over HTTP

Serve the files back through a script that checks authorisation and sets headers deliberately:

$file = $db->findUpload($_GET['id']);          // your own lookup, not a path
$path = '/var/www/uploads/' . $file['stored_name'];

header('Content-Type: ' . $file['mime']);
header('Content-Disposition: attachment; filename="'
    . rawurlencode($file['original_name']) . '"');
header('X-Content-Type-Options: nosniff');
readfile($path);

Content-Disposition: attachment and nosniff together stop the browser rendering the file inline and stop it second-guessing the type — which is what turns an "image" containing HTML into an XSS.

5. If it must live under the web root, disable execution

Sometimes the directory has to be public. Then remove the ability to run anything in it.

Apache — in the upload directory:

php_flag engine off
<FilesMatch "\.(php|php[3-8]|phtml|phar|inc|htaccess)$">
    Require all denied
</FilesMatch>

Nginx — never pass that path to the interpreter:

location ^~ /uploads/ {
    location ~ \.php$ { return 403; }
}

IIS — remove the handler for the folder:

<location path="uploads">
  <system.webServer><handlers><clear /></handlers></system.webServer>
</location>

Also block .htaccess uploads explicitly on Apache: a writable directory plus an uploaded .htaccess lets an attacker re-enable PHP for any extension they like.

6. Validate images by decoding them

For images, verify the file actually decodes rather than merely starting with the right magic bytes:

$info = @getimagesize($path);
if ($info === false || !in_array($info[2], [IMAGETYPE_JPEG, IMAGETYPE_PNG, IMAGETYPE_GIF], true)) {
    throw new RuntimeException('Not a valid image.');
}

Stronger still, re-encode it. Reading the pixels and writing a fresh file discards every chunk of appended payload, EXIF comment and trailing archive:

$img = imagecreatefromstring(file_get_contents($path));
if ($img === false) { throw new RuntimeException('Not a valid image.'); }
imagejpeg($img, $target, 90);      // the saved file is entirely PHP's own output
imagedestroy($img);

Note this loads the image into memory, so cap dimensions before decoding — a "decompression bomb" is a small file that expands to gigabytes of bitmap.

SVG is not a safe image format. It is XML, it can contain <script>, and browsers execute it when the file is served inline. Either refuse SVG, sanitise it with a dedicated library, or serve it only as an attachment with nosniff.

7. Enforce size limits on both sides

Client-side checks are for user experience; the server enforces the rule. Check $_FILES['file']['size'], and configure upload_max_filesize / post_max_size so a hostile client cannot exhaust your disk with one request. The size limits article covers how those interact, including the web-server caps that apply before PHP.

8. Use the upload-specific functions

if (!is_uploaded_file($file['tmp_name'])) {
    throw new RuntimeException('Not an uploaded file.');
}
move_uploaded_file($file['tmp_name'], $target);

Both verify the path was produced by this request's upload handling. Using copy() or rename() with a path drawn from user input is how a handler gets tricked into moving a file it should never touch.

9. Set permissions deliberately

chmod($target, 0644);        // readable, never executable
// the directory: 0755, owned by a user the web server cannot write to elsewhere

Uploads should never be 0777. If a stray interpreter is ever reachable, the execute bit is what turns an uploaded file into a running process.

10. Round out the request

A minimal handler that follows the list

<?php
declare(strict_types=1);

const UPLOAD_DIR = '/var/www/uploads';          // outside the web root
const MAX_BYTES  = 5 * 1024 * 1024;
const ALLOWED    = ['image/jpeg' => 'jpg', 'image/png' => 'png', 'application/pdf' => 'pdf'];

$file = $_FILES['document'] ?? null;

if ($file === null || $file['error'] !== UPLOAD_ERR_OK) {
    throw new RuntimeException('Upload failed.');
}
if (!is_uploaded_file($file['tmp_name']) || $file['size'] > MAX_BYTES) {
    throw new RuntimeException('Upload rejected.');
}

$mime = (new finfo(FILEINFO_MIME_TYPE))->file($file['tmp_name']);
if (!isset(ALLOWED[$mime])) {
    throw new RuntimeException('File type not permitted.');
}

$stored = bin2hex(random_bytes(16)) . '.' . ALLOWED[$mime];
if (!move_uploaded_file($file['tmp_name'], UPLOAD_DIR . '/' . $stored)) {
    throw new RuntimeException('Could not store the file.');
}
chmod(UPLOAD_DIR . '/' . $stored, 0644);

// keep the original name as data, never as a path
$db->recordUpload($stored, $file['name'], $mime, $file['size'], $userId);

Where a component helps

PHP File Uploader handles the transfer and gives you the file server-side to validate with exactly the code above — the checks in this article stay your responsibility, and should. What it removes is the class of mistakes people make while hand-rolling the transfer: it streams to a temporary location rather than a public folder, hands you the file through an object rather than a path from the request, and applies size and extension rules before a byte is sent. See the custom validation demo for server-side validation hooks and the custom handler demo for taking control of where files land.