An upload form lets strangers put files on your server. A careless one can hand over the whole site. Follow these rules every time.
The rules
- Check the upload error code.
- Limit the size.
- Check the real content type, not the file name or the browser’s
typevalue. - Allow only the types you need (an allow-list).
- Never use the visitor’s file name. Generate your own.
- Store files where PHP cannot run, ideally outside the public folder.
- Serve them safely, with the correct headers.
A validation function
This function checks what it can before the file is moved. We test it with real temporary files: one genuine PNG image and one PHP script that has been renamed to photo.jpg.
<?php
function validateImageUpload(array $file, int $maxBytes = 2_000_000): ?string
{
if (($file["error"] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_OK) {
return "The upload failed.";
}
if (($file["size"] ?? 0) > $maxBytes) {
return "The file is too large.";
}
$finfo = new finfo(FILEINFO_MIME_TYPE);
$mime = $finfo->file($file["tmp_name"]);
$allowed = ["image/jpeg", "image/png", "image/webp"];
if (!in_array($mime, $allowed, true)) {
return "Only JPG, PNG and WebP images are allowed.";
}
if (getimagesize($file["tmp_name"]) === false) {
return "The file is not a valid image.";
}
return null; // null means "no problem found"
}
$png = base64_decode("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==");
file_put_contents("upload.tmp", $png);
file_put_contents("script.tmp", "<?php echo 'evil';");
$goodFile = ["name" => "x.png", "error" => 0, "size" => filesize("upload.tmp"), "tmp_name" => "upload.tmp"];
$badFile = ["name" => "photo.jpg", "error" => 0, "size" => filesize("script.tmp"), "tmp_name" => "script.tmp"];
var_dump(validateImageUpload($goodFile));
var_dump(validateImageUpload($badFile));
NULL
string(42) "Only JPG, PNG and WebP images are allowed."
The file named photo.jpg was rejected, because finfo looked at its bytes and saw a script, not an image.
Choose the extension yourself
Never take the extension from the visitor’s file name. Choose it from the detected type.
<?php
function extensionFor(string $mime): ?string
{
return ["image/jpeg" => "jpg", "image/png" => "png", "image/webp" => "webp"][$mime] ?? null;
}
var_dump(extensionFor("image/png"));
var_dump(extensionFor("text/x-php"));
string(3) "png"
NULL
Generate the file name
A random name stops guessing, overwriting and tricks such as shell.php.jpg or ../../index.php.
<?php
$name = bin2hex(random_bytes(16)) . ".png";
echo strlen($name);
36
Save the file
<?php
$error = validateImageUpload($_FILES["photo"] ?? []);
if ($error !== null) {
exit($error);
}
$mime = (new finfo(FILEINFO_MIME_TYPE))->file($_FILES["photo"]["tmp_name"]);
$name = bin2hex(random_bytes(16)) . "." . extensionFor($mime);
$target = dirname(__DIR__) . "/storage/uploads/" . $name; // outside the public folder
if (!move_uploaded_file($_FILES["photo"]["tmp_name"], $target)) {
exit("Could not save the file.");
}
// store $name in the database, not the visitor's original name
Where to store the files
- Best: outside the public web folder. PHP reads and sends the file through a script that checks permissions.
- If the folder must be public (for example for profile pictures), block code execution there. On Apache, put this in
.htaccessinside the uploads folder:
php_flag engine off
RemoveHandler .php .phtml .php5
<FilesMatch "\.(php|phtml|phar)$">
Require all denied
</FilesMatch>
An uploaded .php file must never be runnable from the browser.
Serve downloads safely
<?php
$file = dirname(__DIR__) . "/storage/uploads/" . basename($storedName);
if (!is_file($file)) {
http_response_code(404);
exit;
}
header("Content-Type: image/png");
header("X-Content-Type-Options: nosniff");
header("Content-Length: " . filesize($file));
readfile($file);
basename() strips any folder parts. nosniff stops browsers from guessing a different type.
Extra protection for images
Re-encoding an image with GD removes anything hidden inside it, such as a script appended to the end of a valid image.
<?php
$image = imagecreatefromstring(file_get_contents($tmpName));
imagepng($image, $target); // writes a clean PNG
imagedestroy($image);
Checklist
- Error code checked
- Size limit in PHP and in
php.ini/ the web server - Real type checked with
finfo - Allow-list of types and extensions
- Random file name, extension chosen by you
- Stored outside the web root, or with execution disabled
- Only logged-in users may upload, and you limit how many
- Original file name (if you show it) is escaped on output
What not to trust
$_FILES["x"]["name"], $_FILES["x"]["type"] and the extension are written by the sender. Only the content, checked by PHP, tells you what the file is.