File uploads are one of the most dangerous features in a PHP website. A profile photo form, a resume upload or a plugin that accepts attachments can all become a way for an attacker to put code on your server. Several of the serious 2026 vulnerabilities in WordPress plugins and PHP applications started with an unsafe upload. This guide explains how upload attacks work, and shows how to build an upload form in PHP that validates files, stores them safely, and keeps uploaded code from ever running.
Quick answer
- Never trust the file name or the type that the browser sends.
- Allow only the file types that you need, and check the real content type with PHP’s finfo.
- Give every file a new random name, and store files outside the web root if you can.
- Stop PHP from running in the upload folder, and set size limits.
- Re-encode images, and scan files if you can.
- Keep your CMS, plugins and PHP updated, because a missing check in one plugin is enough.
How a file upload attack works
The attacker uploads a file that contains PHP code, for example a small script disguised as a picture. If your site saves it in a folder that the web server can reach, and the server is willing to run it as PHP, the attacker opens its address in a browser, and their code runs on your server. This kind of script is called a web shell, and it gives them control of your site.
Common tricks include:
- A fake type. The browser sends a content type of image/png for a script. The browser is controlled by the attacker, so this value means nothing.
- A fake extension. A file named shell.php.jpg, or a script saved with an extension that your server treats as PHP, such as .phtml or .phar.
- A file that is two things at once. A file that begins like a real image but also contains PHP code (a polyglot).
- A dangerous file name. A name that contains ../ to write outside the upload folder, or the name of an existing file to overwrite it.
- Active content. An SVG image with a script inside, or an HTML file, which can lead to XSS.
- A very large file, to fill your disk or crash your server.
What PHP gives you
When a form uploads a file, PHP fills the $_FILES array. Each file has these fields:
Field | What it holds | Trust it? |
|---|---|---|
name | The original file name from the visitor | No |
type | The content type that the browser claims | No |
tmp_name | The temporary path where PHP saved the file | Yes, it is created by PHP |
error | An error code, UPLOAD_ERR_OK when it worked | Yes |
size | The size in bytes | Check it, but also set limits |
Your form must use method=”post” and enctype=”multipart/form-data”.
A secure upload function
This function checks the upload, finds the real type from the file content, accepts only an allowlist of images, and creates a random name:
<?php
function validate_upload(array $file): string
{
if ($file['error'] !== UPLOAD_ERR_OK) {
throw new RuntimeException('Upload failed');
}
if ($file['size'] > 2 * 1024 * 1024) {
throw new RuntimeException('File too large');
}
// Find the real type from the content, not from the name or the browser
$finfo = new finfo(FILEINFO_MIME_TYPE);
$mime = $finfo->file($file['tmp_name']);
$allowed = [
'image/jpeg' => 'jpg',
'image/png' => 'png',
'image/webp' => 'webp',
];
if (!isset($allowed[$mime])) {
throw new RuntimeException('File type not allowed: ' . $mime);
}
// The file must also be a readable image
if (@getimagesize($file['tmp_name']) === false) {
throw new RuntimeException('Not a valid image');
}
// New random name, with an extension that comes from our own list
return bin2hex(random_bytes(16)) . '.' . $allowed[$mime];
}Then save the file with move_uploaded_file(), which also checks that the file really came from an upload:
try {
$name = validate_upload($_FILES['photo']);
$target = '/var/app/uploads/' . $name; // outside the web root
if (!move_uploaded_file($_FILES['photo']['tmp_name'], $target)) {
throw new RuntimeException('Could not save the file');
}
chmod($target, 0644);
echo 'Uploaded';
} catch (RuntimeException $e) {
http_response_code(400);
echo htmlspecialchars($e->getMessage(), ENT_QUOTES, 'UTF-8');
}We ran this validation on three test files with PHP 8.3:
Test file | Result |
|---|---|
A real PNG image | Accepted, with a random name such as 45a8c02f92e050ecff1a4f85622bc92e.png |
A PHP script saved as shell.png | Rejected: File type not allowed: text/x-php |
A file that starts with a GIF header and contains PHP | Detected as image/gif, so it is rejected because GIF is not in our allowlist |
The check looks at what is inside the file, so renaming a script to .png does not help the attacker. The third test shows why an allowlist matters, and why checking the header is not the last step. A file that looks like an allowed image can still carry code, so read on.
Re-encode images
The safest way to deal with a file that might be both an image and code is to decode the image and save a new one. The new file contains only pixel data, and any hidden code or metadata is dropped. With the GD extension:
$image = imagecreatefromstring(file_get_contents($file['tmp_name']));
if ($image === false) {
throw new RuntimeException('Not a valid image');
}
imagepng($image, $target); // or imagejpeg($image, $target, 85)This needs the GD or Imagick extension. It also resizes your images if you want, and it works well for profile pictures and similar uploads.
Store files outside the web root
The best place for uploads is a folder that the web server cannot serve directly, such as /var/app/uploads when your site lives in /var/www/html. Then no file in it can ever be run through the web. If visitors need to see the files, serve them through a PHP script that checks permissions and sends the right headers:
$path = '/var/app/uploads/' . basename($name);
if (!is_file($path)) {
http_response_code(404);
exit;
}
header('Content-Type: image/png');
header('X-Content-Type-Options: nosniff');
header('Content-Length: ' . filesize($path));
readfile($path);basename() removes any folder parts from the name. For files that are not images, such as PDFs and documents, add Content-Disposition: attachment so that they are downloaded and not shown in the browser.
If uploads must be inside the web root
Some systems, such as WordPress, keep uploads in a public folder. Then it is essential to stop the server from running scripts there.
Nginx:
location ~* ^/uploads/.*\.(php|phtml|phar|php[0-9])$ {
deny all;
}Make sure that no other location block sends files in the uploads folder to PHP-FPM.
Apache (a .htaccess file in the uploads folder):
<FilesMatch "\.(php|phtml|phar|php[0-9])$">
Require all denied
</FilesMatch>After you add the rule, test it. Upload a harmless text file named test.php in a test environment, and check that opening it gives an error and does not run.
Set size and count limits
Set limits in php.ini, and in your web server:
upload_max_filesize = 2M
post_max_size = 8M
max_file_uploads = 5- Nginx: client_max_body_size 8M;
- Apache: LimitRequestBody 8388608
Also check the size in your code, as in the validate_upload() function above.
Handle file names with care
- Do not use the visitor’s file name to save the file. Create your own name, as above.
- If you want to show the original name, save it in the database, and escape it whenever you print it, because it can contain HTML. See our guide to XSS in PHP.
- Use basename() if you have to work with a name that came from outside.
- Never build a path from input without checking it, because ../ can move the file outside the upload folder.
Extra protections
- Allow uploads only to logged-in users where possible, protect the form with a CSRF token, and add rate limits.
- Scan files for malware with ClamAV or a similar scanner, especially for documents that other people will open.
- Be careful with SVG. An SVG file can contain scripts. Do not allow it unless you need it, and if you do, sanitise it and serve it with a strict Content Security Policy.
- Do not open uploaded documents inside your own pages. Offer them as downloads.
- Log uploads: the user, the time, the IP address, the original name and the new name.
- Check permissions: files 0644, folders 0755, and not owned by root. Uploaded files should never have the execute bit.
Uploads in CodeIgniter 4
CodeIgniter 4 has a validation helper and a file class that makes this easier:
$rules = [
'photo' => 'uploaded[photo]|max_size[photo,2048]|is_image[photo]|mime_in[photo,image/jpg,image/jpeg,image/png]|ext_in[photo,png,jpg,jpeg]',
];
if (!$this->validate($rules)) {
return redirect()->back()->with('errors', $this->validator->getErrors());
}
$file = $this->request->getFile('photo');
if ($file->isValid() && !$file->hasMoved()) {
$newName = $file->getRandomName();
$file->move(WRITEPATH . 'uploads', $newName);
}The max_size value is in kilobytes. WRITEPATH is outside the public folder in a standard CodeIgniter 4 setup, so the files are not served directly.
Uploads in WordPress
In plugins and themes, use WordPress’s own functions and do not write your own upload code. wp_handle_upload() saves the file safely, and wp_check_filetype_and_ext() checks the type. Keep your plugins updated, and delete the ones that you do not use, because upload bugs in plugins are a common way that WordPress sites are hacked.
If you already have an upload form
- Check which file types it accepts. Reduce them to the minimum.
- Look for PHP files that already sit in the upload folder:
find /var/www -type f \( -name '*.php' -o -name '*.phtml' -o -name '*.phar' \) -path '*/uploads/*'- Stop script execution in the folder, and test that it works.
- Add the size limits.
- If you find unknown scripts, treat the site as hacked. See our guide to checking whether your PHP website was hacked.
Quick checklist
- Allowlist of file types, checked with finfo on the real content
- New random file name, and the extension comes from your own list
- Files stored outside the web root, or PHP execution blocked in the folder
- Images re-encoded with GD or Imagick
- Size, count and permission limits set
- CSRF token, login and rate limits on the form
- Original names escaped when displayed
- Plugins, CMS and PHP updated
FAQs
Is checking the file extension enough?
No. An attacker can name a script anything. Check the real content with finfo, use an allowlist, and create your own extension.
Can I trust $_FILES[‘type’]?
No. The browser sends it, so an attacker can set it to any value.
Is getimagesize() enough to prove that a file is a safe image?
No. A file can be a valid image and also contain code. Re-encode images, and block script execution in the folder.
Where should I store uploaded files?
Outside the web root, and serve them through a script if visitors need them. If that is not possible, block PHP execution in the upload folder.
Is it safe to allow PDF or DOCX uploads?
Only with care. Allow the exact types you need, scan them, store them outside the web root, and serve them as downloads.
Does a WAF protect my upload form?
It can block many attempts, but WAF upload checks have been bypassed, including in widely used rule sets in 2026. Treat it as an extra layer, not as a replacement for safe code.
