Why File Uploads Are More Dangerous Than You Think
Every time you let a user upload a file, you are handing them a potential weapon. A poorly secured upload form can allow attackers to upload PHP shells disguised as images, overwrite critical files, exhaust your server's disk space, or serve malware to other users. On shared cPanel hosting — where a single compromised account can affect neighboring sites — the stakes are even higher.
The good news is that a solid, layered approach to file upload security is not complicated. It just requires being deliberate at every step. This tutorial walks through a complete, production-ready upload handler in PHP, designed specifically for the shared hosting environment most Indonesian freelancers and indie developers actually work in.
Step 1: Start with the HTML Form
Before any PHP runs, the form itself should be locked down. Always use enctype="multipart/form-data", use POST, and add a CSRF token field. We will not cover the full CSRF implementation here, but the token field is a placeholder you should wire up to your session-based token system.
<form action="upload.php" method="POST" enctype="multipart/form-data"> <input type="hidden" name="csrf_token" value="<?= $_SESSION['csrf_token'] ?>"> <input type="file" name="user_upload" accept="image/jpeg,image/png,image/webp"> <button type="submit">Upload</button> </form>
The accept attribute gives a hint to the browser but provides zero security on its own. Never rely on it. It is purely a UX convenience.
Step 2: Validate the Upload in PHP — Layer by Layer
Your PHP handler should treat every incoming file as guilty until proven innocent. Here is the pattern I use in every project.
Check for Upload Errors First
if ($_FILES['user_upload']['error'] !== UPLOAD_ERR_OK) {
die('Upload failed with error code: ' . $_FILES['user_upload']['error']);
}
PHP defines several error constants: UPLOAD_ERR_INI_SIZE, UPLOAD_ERR_FORM_SIZE, UPLOAD_ERR_PARTIAL, and more. In production, log these rather than exposing them to the user.
Enforce a File Size Limit in PHP
Do not rely solely on php.ini limits. Enforce your own ceiling in code.
$maxBytes = 2 * 1024 * 1024; // 2MB
if ($_FILES['user_upload']['size'] > $maxBytes) {
die('File exceeds the 2MB limit.');
}
Validate the MIME Type Using Fileinfo — Not the Extension
This is where most tutorials fail. Checking the file extension is trivially bypassed by renaming shell.php to shell.jpg. You must read the actual file contents and inspect the real MIME type.
$allowedMimes = ['image/jpeg', 'image/png', 'image/webp'];
$finfo = new finfo(FILEINFO_MIME_TYPE);
$detectedMime = $finfo->file($_FILES['user_upload']['tmp_name']);
if (!in_array($detectedMime, $allowedMimes, true)) {
die('Invalid file type detected.');
}
The finfo extension reads magic bytes from the file itself — the actual binary signature at the start of the file — rather than trusting user-supplied metadata.
Cross-Reference with a Whitelist of Extensions
Use both checks. Map allowed MIME types to their expected extensions and verify the uploaded file's extension falls within that map.
$mimeToExtension = [ 'image/jpeg' => 'jpg', 'image/png' => 'png', 'image/webp' => 'webp', ]; $expectedExtension = $mimeToExtension[$detectedMime];
Step 3: Always Rename the File
Never, ever store the file under the name the user provided. The original filename is untrusted input. It can contain path traversal sequences like ../../config.php, null bytes, or simply overwrite an existing file if you have no uniqueness check.
Generate a new name using a combination of a cryptographically random string and the validated extension.
$newFilename = bin2hex(random_bytes(16)) . '.' . $expectedExtension; // Example result: a3f9c21b44e87d0c5f12a9b3c6d48e1f.jpg
This approach guarantees uniqueness, eliminates user-controlled characters entirely, and makes it impossible for an attacker to predict the stored filename — useful if your storage directory is ever accidentally exposed.
Step 4: Store Files Outside the Web Root
On cPanel hosting, your public files live inside public_html. That means any file you drop inside public_html/uploads/ is directly accessible via a URL. If an attacker manages to upload a PHP file and the server executes it, you have a serious problem.
The safest approach is to store uploads outside public_html.
// On cPanel, your home directory is typically /home/yourusername/
$uploadDir = dirname($_SERVER['DOCUMENT_ROOT']) . '/private_uploads/';
if (!is_dir($uploadDir)) {
mkdir($uploadDir, 0755, true);
}
To serve the file to a user, write a PHP delivery script inside public_html that reads the file and streams it with the correct headers — never exposing the real path.
// serve-image.php
$filename = basename($_GET['file']); // basename prevents path traversal
$filepath = dirname($_SERVER['DOCUMENT_ROOT']) . '/private_uploads/' . $filename;
if (!file_exists($filepath)) {
http_response_code(404);
exit;
}
$finfo = new finfo(FILEINFO_MIME_TYPE);
header('Content-Type: ' . $finfo->file($filepath));
readfile($filepath);
If You Must Store Inside public_html
If your hosting situation forces files inside public_html/uploads/, at minimum add a .htaccess file inside the uploads directory to block PHP execution entirely.
# public_html/uploads/.htaccess php_flag engine off AddType text/plain .php .php3 .php4 .php5 .phtml .phar
This tells Apache to serve PHP files as plain text instead of executing them. It is a critical safety net for shared hosting environments.
Step 5: Move the File and Confirm
Only use move_uploaded_file() — never copy() or rename() for uploaded files. PHP's built-in function verifies the file was actually uploaded via HTTP POST, preventing race condition attacks against the temp file.
$destination = $uploadDir . $newFilename;
if (!move_uploaded_file($_FILES['user_upload']['tmp_name'], $destination)) {
die('Failed to move uploaded file.');
}
echo 'File uploaded successfully: ' . htmlspecialchars($newFilename);
Step 6: Log Everything on Shared Hosting
On shared hosting you often cannot touch the server's main error logs. Set up your own lightweight log for upload events — successes and failures alike. This becomes invaluable when something goes wrong at 2am.
function logUploadEvent(string $message, string $level = 'INFO'): void {
$logFile = dirname($_SERVER['DOCUMENT_ROOT']) . '/logs/uploads.log';
$entry = sprintf("[%s] [%s] %s\n", date('Y-m-d H:i:s'), $level, $message);
file_put_contents($logFile, $entry, FILE_APPEND | LOCK_EX);
}
logUploadEvent('Uploaded: ' . $newFilename . ' | MIME: ' . $detectedMime . ' | IP: ' . $_SERVER['REMOTE_ADDR']);
Putting It All Together
Here is the complete flow in plain English so you can audit your own implementation against it:
- Check the PHP error code — reject anything that is not
UPLOAD_ERR_OK - Enforce a file size limit — in your PHP code, not just
php.ini - Detect the real MIME type — using
finfoon the temp file - Cross-reference extension — map MIME to expected extension, ignore user's original name
- Generate a random filename — using
bin2hex(random_bytes(16)) - Store outside the web root — or block execution with
.htaccessif you cannot - Move with
move_uploaded_file()— never any other function - Log the event — IP, filename, MIME, timestamp
Final Thoughts
File upload security is not glamorous work. It will not show up in your portfolio and users will never thank you for it. But a single exploited upload form can take down your client's site, expose their database, and damage your reputation as a developer.
The approach above is deliberately layered — each check exists because the ones before it can be bypassed in isolation. On shared cPanel hosting in particular, where you have limited ability to configure the server itself, having this kind of defense-in-depth in your PHP code is not optional. It is the minimum.
Treat every uploaded file as a potential attack. Validate ruthlessly, rename always, and store defensively.
Build this pattern once into a reusable class or function, drop it into your projects, and stop thinking about it. That is the real win.