Understanding the Security Risk Behind File Upload Forms
File upload functionality is one of the most exploited attack surfaces in PHP applications. An attacker who successfully uploads a malicious PHP script can achieve arbitrary code execution on your server, giving them the ability to read database credentials, access customer data, use your server to send spam, or move laterally to other systems on your network.
The vulnerability rarely exists in the upload mechanism itself. It lives in how uploaded files are stored, served, and processed after the upload completes. A properly secured upload form validates what it receives, stores files in locations the web server cannot execute, and serves them through controlled scripts that enforce access controls.
For a broader view of securing PHP applications against common vulnerabilities, a practical PHP security checklist covers input validation, output encoding, and other protections that complement file upload security.
The Three-Layer Defence Model for File Uploads
Securing file uploads requires three independent layers of protection that work together. If any single layer fails, the others continue to provide protection. These layers are content validation, safe storage, and controlled access.
Content validation ensures the uploaded file is what it claims to be, not a PHP script disguised with a legitimate extension. Safe storage places files where the web server cannot serve them as executable code. Controlled access means every file download goes through a script that checks authentication and authorisation before serving the file.
Storing Uploaded Files Outside the Web Root
The foundational control for secure file uploads is storing files in a directory the web server cannot serve directly. If your document root is /var/www/html/, place uploads in /var/www/uploads/ or /srv/storage/uploads/.
The reason this matters is straightforward. A web server maps incoming HTTP requests to files on disk. If a file exists outside the document root, no URL can make the server return it. The only access path is through a PHP script you write, which means you control every aspect of how the file is delivered.
# Create the upload directory outside the document root
mkdir -p /srv/storage/uploads
chown -R www-data:www-data /srv/storage/uploads
chmod -R 750 /srv/storage/uploads
The upload endpoint should move the file from its temporary upload location to the storage directory using a generated filename, never the original one. Store the mapping between the generated filename and the original name in your database. When a user requests a download, they request the file by its database ID. The download script looks up the actual file path and serves it.
Using the original filename directly introduces path traversal risk. An attacker uploading a file named "../../../etc/passwd" could overwrite system files if the write path is not sanitised. Generate filenames that cannot contain path traversal characters:
<?php
$upload_dir = '/srv/storage/uploads/';
$original_name = $_FILES['upload']['name'];
// Generate a random filename, preserve only the last extension
$extension = strtolower(pathinfo($original_name, PATHINFO_EXTENSION));
$allowed_extensions = ['jpg', 'jpeg', 'png', 'gif', 'pdf'];
if (!in_array($extension, $allowed_extensions, true)) {
http_response_code(400);
exit('Invalid file type');
}
$generated_name = bin2hex(random_bytes(16)) . '.' . $extension;
$target_path = $upload_dir . $generated_name;
if (!move_uploaded_file($_FILES['upload']['tmp_name'], $target_path)) {
http_response_code(500);
exit('Upload failed');
}
// Store the mapping (generated_name, original_name, user_id, upload_time) in your database
?>
The bin2hex(random_bytes(16)) call generates a 32-character hexadecimal string with no directory separators or special characters. Even if an attacker controls the extension (which is validated against a whitelist), the random prefix ensures unique, unpredictable filenames.
Validating File Content, Not Just Extensions
Extension validation alone is insufficient. A file named malicious.php.jpg contains PHP code, not a valid JPEG image. The extension tells the browser what MIME type to report, not what the file actually contains.
PHP provides functions that inspect file content to determine actual type. For images, getimagesize() or exif_imagetype() attempt to parse the file as an image format. For PDFs, check the file signature. The first bytes of a PDF file are always %PDF-. For any file type, finfo_file() with the FILEINFO_MIME_TYPE constant reads the file's binary content and matches it against known file signatures.
<?php
// Check actual MIME type using file signature
$finfo = new finfo(FILEINFO_MIME_TYPE);
$mime_type = $finfo->file($_FILES['upload']['tmp_name']);
$allowed_mimes = [
'image/jpeg' => 'jpg',
'image/png' => 'png',
'image/gif' => 'gif',
'application/pdf' => 'pdf'
];
if (!array_key_exists($mime_type, $allowed_mimes)) {
http_response_code(400);
exit('File type not permitted');
}
// Additional image validation for image uploads
$image_mimes = ['image/jpeg', 'image/png', 'image/gif'];
if (in_array($mime_type, $image_mimes, true)) {
$image_info = @getimagesize($_FILES['upload']['tmp_name']);
if ($image_info === false) {
http_response_code(400);
exit('Invalid image file');
}
// Check image dimensions to prevent pixel flood attacks
if ($image_info[0] > 10000 || $image_info[1] > 10000) {
http_response_code(400);
exit('Image dimensions excessive');
}
}
?>
The finfo class reads binary content and matches it against a database of known file signatures, not the browser-supplied Content-Type header. The getimagesize() call adds protection against crafted files that have valid image MIME types but exploit parser vulnerabilities. The dimension check prevents pixel flood denial-of-service attacks where a small file expands to enormous memory usage when loaded.
Blocking PHP Execution in the Upload Directory
Even with storage outside the web root, add a layer of defence that prevents PHP execution if files are accidentally placed within the document root. This catches misconfiguration errors and protects against rewrite rule mistakes that might expose files.
# In the upload directory .htaccess (Apache)
<FilesMatch "\.ph(p?|tml|ar)$">
Order Deny,Allow
Deny from all
</FilesMatch>
# For Nginx, in the upload location block
location /srv/storage/uploads {
internal;
# Deny all PHP execution attempts explicitly
location ~ \.php$ {
deny all;
}
}
For detailed guidance on hardening Apache configurations, including proper .htaccess rules and server-level access controls, a practical Apache security configuration guide covers these settings in more depth.
Set directory permissions to prevent execution of uploaded files. A directory with 755 permissions allows traversal but not direct execution of files within it. Setting the directory to 750 (owner read/write/execute, group read/execute, no access for others) and ensuring uploaded files receive 640 permissions prevents the execute bit from being set:
chmod 750 /srv/storage/uploads
# Files uploaded will inherit the directory group (www-data)
# Explicitly set file permissions after upload
chmod 640 $target_path
PHP's open_basedir restriction limits which filesystem paths PHP scripts can access. Set it to your application root and the upload storage directory only. This does not prevent execution of uploaded files but limits what a compromised script can access elsewhere on the filesystem:
# In php.ini or php-fpm pool config
open_basedir = /var/www/html:/srv/storage/uploads:/var/log/php
Building a Secure Download Script
Files stored outside the web root are inaccessible via direct URL. Every file request must go through a PHP download script that you control. This script is a critical security layer that enforces authentication, checks authorisation, and serves the file correctly.
<?php
session_start();
if (!isset($_SESSION['user_id'])) {
http_response_code(401);
exit('Authentication required');
}
$file_id = filter_input(INPUT_GET, 'id', FILTER_VALIDATE_INT);
if ($file_id === false || $file_id === null) {
http_response_code(400);
exit('Invalid file ID');
}
$pdo = new PDO('mysql:host=localhost;dbname=app', 'app_user', 'password');
$stmt = $pdo->prepare("SELECT filename, original_name, mime_type FROM uploads WHERE id = ? AND user_id = ?");
$stmt->execute([$file_id, $_SESSION['user_id']]);
$file = $stmt->fetch(PDO::FETCH_ASSOC);
if (!$file) {
http_response_code(404);
exit('File not found');
}
$filepath = '/srv/storage/uploads/' . $file['filename'];
if (!file_exists($filepath)) {
http_response_code(404);
exit('File not found on disk');
}
header('Content-Type: ' . $file['mime_type']);
header('Content-Disposition: inline; filename="' . $file['original_name'] . '"');
header('Content-Length: ' . filesize($filepath));
readfile($filepath);
?>
Never accept a filename from the request and pass it directly to readfile(). A request like /download.php?file=../../../etc/passwd is a path traversal attack if the script uses the user-supplied value. Using an integer file ID and looking up the actual filename from a database eliminates this entire class of vulnerability. There is no user-supplied path involved in the filesystem read.
If your application needs CSRF protection on download links (for example, if downloads trigger expensive operations or expose sensitive data), apply token validation to download requests as you would to any state-changing operation. A CSRF protection implementation guide covers how to add token validation to PHP forms and requests.
Scanning Uploaded Files for Malware
For higher-security applications, integrate ClamAV to scan files before they are permanently stored. A PHP script that validates a file, runs it through clamscan, and only moves it to storage if the scan passes catches malicious files that bypass content validation:
<?php
// Move uploaded file to temp location
$temp_path = $_FILES['upload']['tmp_name'];
$scan_result = shell_exec('clamscan --remove=no ' . escapeshellarg($temp_path) . ' 2>&1');
if (strpos($scan_result, 'FOUND') !== false) {
unlink($temp_path);
http_response_code(400);
exit('File rejected: malware detected');
}
// File is clean, move to storage
move_uploaded_file($temp_path, $target_path);
?>
The --remove=no flag tells ClamAV not to delete the file if found. That remains your responsibility. Running scans through shell spawns adds latency. For high-volume uploads, consider running ClamAV as a daemon (clamd) and using clamdscan, or a PHP extension for faster scanning.
A simpler heuristic that catches most malicious PHP uploads: reject any file containing <?php or <script. These patterns should not appear in legitimate user uploads:
<?php
$content = file_get_contents($_FILES['upload']['tmp_name']);
if (preg_match('/<\?php/i', $content) || preg_match('/<script.*>/i', $content)) {
http_response_code(400);
exit('File contains disallowed content');
}
?>
Rate Limiting Upload Requests
An attacker can fill your disk by uploading many small files rapidly, even if each file passes individual validation and size checks. Implement per-user or per-IP rate limiting on upload endpoints.
<?php
$redis = new Redis();
$redis->connect('127.0.0.1', 6379);
$ip = $_SERVER['REMOTE_ADDR'];
$key = 'upload_rate:' . $ip;
$count = (int)$redis->get($key);
if ($count >= 10) {
http_response_code(429);
exit('Upload rate limit exceeded. Try again later.');
}
$redis->incr($key);
if ($count === 0) {
$redis->expire($key, 3600); // Reset counter every hour
}
// Proceed with upload
?>
Configure PHP-level upload limits in php.ini. The upload_max_filesize and post_max_size directives set maximum sizes for individual uploads and total POST request size respectively. Set these to values slightly above what your application actually needs:
; In php.ini
upload_max_filesize = 5M
post_max_size = 10M
Configure Nginx to enforce request body size limits at the web server level, so oversized uploads are rejected before they reach PHP:
# In Nginx server block
client_max_body_size 5M;
Logging Upload Activity for Security and Compliance
Log every upload attempt with enough detail to reconstruct events during a security incident. Record the authenticated user, original filename, generated storage filename, file size, detected MIME type, result (success or failure), and the uploader's IP address.
<?php
$log_data = [
'user_id' => $_SESSION['user_id'] ?? null,
'ip' => $_SERVER['REMOTE_ADDR'],
'original_name' => $_FILES['upload']['name'],
'generated_name' => $generated_name,
'size' => $_FILES['upload']['size'],
'mime_type' => $mime_type,
'result' => 'success',
'error' => null
];
// Write to a JSON log file or send to a logging service
file_put_contents('/var/log/uploads.json', json_encode($log_data) . "\n", FILE_APPEND);
?>
Retain upload logs for at least 90 days. If a security incident occurs involving uploaded files, your logs tell you what was uploaded, by whom, when, and from which IP address. This data also supports GDPR accountability requirements, where you may need to demonstrate what personal data was processed and for what purpose.
Special Handling Required for SVG Uploads
SVG files are XML documents that can contain embedded JavaScript through event handlers, onload attributes, or animation elements. An SVG uploaded as a profile picture and displayed inline can execute JavaScript in the viewer's browser if it contains script tags.
Browsers block script execution in SVG files referenced as images, but an SVG served with an image/svg+xml content type and displayed inline can run scripts. This makes SVG a higher-risk upload type than raster images such as JPEGs or PNGs.
If you accept SVG uploads, sanitise them using a library like DOMPurify before storage. Alternatively, convert SVG uploads to raster format on the server using Imagick and store the converted file instead of the original. This eliminates the embedded script risk entirely:
<?php
// Convert SVG to PNG using Imagick
$imagick = new Imagick();
$imagick->readImageBlob(file_get_contents($uploaded_svg_path));
$imagick->setImageFormat('png');
$png_path = preg_replace('/\.svg$/', '.png', $target_path);
$imagick->writeImage($png_path);
$imagick->destroy();
// Delete the original SVG
unlink($target_path);
$target_path = $png_path;
?>
Setting Content-Disposition Headers Correctly
When serving uploaded files, set the Content-Disposition header appropriately. Use inline for files the browser should display (images, PDFs) and attachment for files that should be downloaded (archives, executables, Office documents).
<?php
// Determine disposition based on MIME type
$inline_mimes = ['image/jpeg', 'image/png', 'image/gif', 'application/pdf'];
$disposition = in_array($file['mime_type'], $inline_mimes, true) ? 'inline' : 'attachment';
header('Content-Disposition: ' . $disposition . '; filename="' . addslashes($file['original_name']) . '"');
?>
Never use the user-supplied original filename directly in the Content-Disposition header without escaping it. A filename containing quotes, backslashes, or non-ASCII characters can cause header injection or be truncated by browsers. Use addslashes() or a dedicated header-safe encoding function. Always use the filename stored in your database, which you control.
Handling ZIP File Uploads Safely
ZIP uploads introduce additional security concerns beyond single-file uploads. A malicious ZIP archive can contain files with path traversal names like ../../etc/passwd that exploit vulnerabilities when extracted. ZIP archives can also be crafted as zip bombs—compressed files that expand to vastly larger sizes when extracted, consuming disk space or CPU.
If your application accepts ZIP uploads, validate the uncompressed size before extraction, check each filename inside the archive for path traversal patterns, and set reasonable limits on the total uncompressed size relative to the compressed size. A ratio above 10:1 warrants investigation.
<?php
$zip = new ZipArchive();
$zip->open($uploaded_zip_path);
$total_uncompressed = 0;
$unsafe_files = [];
for ($i = 0; $i < $zip->numFiles; $i++) {
$filename = $zip->getNameIndex($i);
// Reject files with path traversal patterns
if (strpos($filename, '..') !== false) {
$unsafe_files[] = $filename;
continue;
}
$stat = $zip->statIndex($i);
$total_uncompressed += $stat['size'];
// Reject if total uncompressed size exceeds 50MB
if ($total_uncompressed > 52428800) {
$zip->close();
http_response_code(400);
exit('Archive exceeds maximum uncompressed size');
}
}
if (!empty($unsafe_files)) {
$zip->close();
http_response_code(400);
exit('Archive contains unsafe filenames');
}
// Safe to extract
$zip->extractTo('/srv/storage/uploads/extracted/');
$zip->close();
?>