Skip to content
← Blog Development September 19, 2026

File Upload Validation in PHP Is Harder Than You Think — Here's Where Most Developers Get It Wrong

10 min read
File Upload Validation in PHP Is Harder Than You Think — Here's Where Most Developers Get It Wrong

I've reviewed a lot of PHP codebases over the years — client projects, freelance handoffs, internal tools inherited from developers who've long since moved on. And if there's one feature that consistently has the most quietly dangerous code in it, it's file uploads.

Not because file uploads are exotic or complex in concept. You drop an <input type="file"> on a form, catch $_FILES in PHP, move the file somewhere, done. It feels like a solved problem. That's exactly the trap.

The real danger isn't that developers don't know about file upload validation — it's that they know just enough to feel confident. They check the extension, maybe check the MIME type from $_FILES['file']['type'], limit the file size, and ship it. I've done this myself. And I've also been the person who came back six months later and found a hole you could drive a truck through.

This post is an honest breakdown of where file upload validation actually fails, what I've learned from fixing those failures, and how I approach it now on PHP projects running on shared cPanel hosting — which has its own set of constraints that most tutorials don't account for.

The Mistake That Looks Like Best Practice

Here's the pattern I see most often. A developer writes something like this:

$allowed_types = ['image/jpeg', 'image/png', 'image/gif'];
$file_type = $_FILES['upload']['type'];

if (!in_array($file_type, $allowed_types)) {
    die('Invalid file type.');
}

$ext = pathinfo($_FILES['upload']['name'], PATHINFO_EXTENSION);
$filename = uniqid() . '.' . $ext;
move_uploaded_file($_FILES['upload']['tmp_name'], 'uploads/' . $filename);

This looks reasonable. It even looks like the developer thought about security. But there are at least four serious problems here, and two of them are in the first three lines.

Problem one: $_FILES['upload']['type'] is sent by the browser. The browser. That value is user-controlled. I can make a PHP webshell with a .php extension report itself as image/jpeg with one line of curl. This check is effectively worthless for security purposes.

Problem two: The file extension from the original filename is also user-controlled. A file named shell.php renamed to shell.jpg.php will have pathinfo return php as the extension on some configurations. And even a clean extension doesn't tell you what's inside the file.

Problem three: The uploads folder is inside public_html, which means Apache can serve files from it directly. If someone uploads a PHP file that gets through your checks, Apache will execute it. Game over.

Problem four: There's no check on $_FILES['upload']['error'] before doing anything with the file. On a shared host with restrictive PHP settings, this silently breaks in ways that are hard to trace. (Speaking of debugging production issues, I wrote about how I handle tricky production errors in PHP in this post on debugging PHP errors on production.)

Validate the File Content, Not the Metadata

The fix for the MIME type problem is to read the actual file contents server-side. PHP's finfo extension does this by examining magic bytes — the first few bytes of a file that indicate what it actually is, regardless of filename or headers.

$finfo = new finfo(FILEINFO_MIME_TYPE);
$real_mime = $finfo->file($_FILES['upload']['tmp_name']);

$allowed_mimes = ['image/jpeg', 'image/png', 'image/webp'];

if (!in_array($real_mime, $allowed_mimes)) {
    // log this — spoofed MIME is worth knowing about
    error_log('Rejected upload with MIME: ' . $real_mime);
    http_response_code(422);
    exit('File type not permitted.');
}

This is meaningfully harder to fake. A PHP file masquerading as a JPEG will fail here because its magic bytes don't start with FF D8 FF. That said, it's not bulletproof for all file types — a polyglot file (a valid JPEG that also contains valid PHP) can still pass MIME checks. For images specifically, the nuclear option is to re-encode through GD or Imagick, which strips any embedded code:

$source = imagecreatefromjpeg($_FILES['upload']['tmp_name']);
imagejpeg($source, $destination_path, 85);
imagedestroy($source);

This only works for images, obviously. For PDFs or other document types, MIME validation plus storage outside the webroot is the safer path.

Generate Your Own Filenames — Every Single Time

I cannot stress this enough: never use $_FILES['upload']['name'] as the actual stored filename. Not even after sanitizing it. The original name is useful for display purposes — store it in a database column if you need to show it to users — but it should never touch your filesystem directly.

My current pattern generates a random filename and validates the extension from a whitelist, independently of what the user sent:

function safe_extension(string $mime): ?string {
    $map = [
        'image/jpeg' => 'jpg',
        'image/png'  => 'png',
        'image/webp' => 'webp',
    ];
    return $map[$mime] ?? null;
}

$ext = safe_extension($real_mime);
if ($ext === null) {
    exit('Unsupported type.');
}

$filename = bin2hex(random_bytes(16)) . '.' . $ext;

random_bytes(16) gives you 32 hex characters of randomness. There is no user input in that filename. Path traversal attacks — where someone passes ../../../etc/passwd as a filename — simply can't reach the stored name because we never used theirs.

Store Outside public_html on cPanel

This is the constraint that shared hosting tutorials routinely skip. On a cPanel account, your webroot is public_html/. Anything inside it is potentially accessible via HTTP. Most people just create an uploads/ folder inside public_html/ and call it done.

The better approach on cPanel is to store uploads one level up — in your home directory, outside public_html. If your cPanel home is /home/yourusername/, you can write to /home/yourusername/uploads/ and Apache will never serve those files directly.

define('UPLOAD_DIR', '/home/yourusername/uploads/');

$destination = UPLOAD_DIR . $filename;

if (!move_uploaded_file($_FILES['upload']['tmp_name'], $destination)) {
    error_log('move_uploaded_file failed for: ' . $filename);
    http_response_code(500);
    exit('Upload failed.');
}

To serve these files to legitimate users, you write a simple PHP proxy script that checks whether the requesting user has permission, then streams the file:

// serve.php?file=abc123.jpg
$requested = basename($_GET['file'] ?? '');
$path = '/home/yourusername/uploads/' . $requested;

if (!file_exists($path) || !is_readable($path)) {
    http_response_code(404);
    exit();
}

// check user permission here
if (!user_can_access($requested)) {
    http_response_code(403);
    exit();
}

$finfo = new finfo(FILEINFO_MIME_TYPE);
header('Content-Type: ' . $finfo->file($path));
header('Content-Length: ' . filesize($path));
readfile($path);

Notice the basename() call on the incoming filename. That strips any directory traversal attempts — someone passing ../../etc/passwd as the file parameter just gets passwd, which won't exist in your upload directory. Simple, effective.

Don't Forget the Error Code and Size Checks

Before you do anything with an uploaded file, check $_FILES['upload']['error']. PHP defines several error constants for this, and ignoring them means you're operating on a potentially incomplete or corrupted upload:

$error_code = $_FILES['upload']['error'];

if ($error_code !== UPLOAD_ERR_OK) {
    $messages = [
        UPLOAD_ERR_INI_SIZE   => 'File exceeds server size limit.',
        UPLOAD_ERR_FORM_SIZE  => 'File exceeds form size limit.',
        UPLOAD_ERR_PARTIAL    => 'File was only partially uploaded.',
        UPLOAD_ERR_NO_FILE    => 'No file was uploaded.',
        UPLOAD_ERR_NO_TMP_DIR => 'Missing temporary folder.',
        UPLOAD_ERR_CANT_WRITE => 'Failed to write to disk.',
        UPLOAD_ERR_EXTENSION  => 'Upload stopped by extension.',
    ];
    $msg = $messages[$error_code] ?? 'Unknown upload error.';
    error_log('Upload error code ' . $error_code . ': ' . $msg);
    http_response_code(400);
    exit($msg);
}

For file size, enforce your own limit in PHP after the error check — don't rely solely on upload_max_filesize in php.ini, because on shared hosting you often can't change that value reliably. Enforce it explicitly:

$max_bytes = 5 * 1024 * 1024; // 5MB

if ($_FILES['upload']['size'] > $max_bytes) {
    http_response_code(413);
    exit('File too large.');
}

Also: never assume you can change upload_max_filesize via .htaccess on shared cPanel. Some hosts allow it, many don't, and the silent failure mode — where your form just seems to reject all uploads with no error — is maddening to debug. Set your own ceiling in application code and communicate it clearly to users.

What I Do Now — The Full Picture

After enough production incidents and code reviews, my current file upload flow looks like this, in order:

  1. Check $_FILES['upload']['error'] === UPLOAD_ERR_OK before touching anything else.
  2. Validate file size against an application-defined limit.
  3. Confirm the file is an actual uploaded file with is_uploaded_file($_FILES['upload']['tmp_name']).
  4. Read MIME type from file contents using finfo, not from the browser-supplied header.
  5. Map that MIME to an allowed extension from a hardcoded whitelist I control.
  6. Generate a random filename using random_bytes().
  7. Move the file to a directory outside public_html.
  8. For images, re-encode through GD to strip any embedded content.
  9. Store the original filename and the generated filename in the database.
  10. Serve files through a PHP script that checks access permissions first.

That's ten steps for what people think is a two-line feature. But each of those steps exists because something broke — or nearly broke — without it.

The Broader Point About "Simple" Features

File uploads are a good example of a class of features that look trivial, have well-documented gotchas, and still get implemented badly on real projects. I think it happens because tutorials optimise for getting something working quickly, not for teaching where the bodies are buried. They show you the happy path. Production doesn't live on the happy path.

If you're building anything in PHP that takes user uploads, treat every file as potentially hostile. That's not paranoia — it's just accurate. The file came from the internet, which means you know nothing about it except what your own validation tells you.

The good news is that the safe approach isn't dramatically more code than the unsafe one. It's mostly about doing the checks in the right order, using the right PHP functions, and choosing where to put files on your server. None of it is rocket science. It's just the kind of knowledge that accumulates from shipping things and watching them break.


Frequently Asked Questions

Is checking the file extension enough to validate a file upload in PHP?

No — file extensions can be faked trivially by any user. You should validate MIME type using finfo_file() server-side, check magic bytes for stricter validation, and enforce file size limits independently of what the browser reports.

Where should uploaded files be stored on cPanel shared hosting?

Store uploads outside the public_html directory whenever possible, so they aren't directly accessible via URL. Serve them through a PHP script that checks permissions, rather than letting Apache serve them directly.

What's the safest way to name uploaded files to prevent path traversal attacks?

Never use the original filename from $_FILES. Generate a new filename using a combination of uniqid(), random_bytes(), or a UUID, then append a validated extension derived from your own MIME-to-extension map. Store the original filename in your database only if you need to display it to the user.

Share Twitter / X LinkedIn

Enjoyed this? Let's build something.

Start a project
Keep reading

More articles

How I Debug PHP Errors on Production Without Losing My Mind (or My Users)
Development September 13, 2026
How I Debug PHP Errors on Production Without Losing My Mind (or My Users)
Production errors are silent killers on shared hosting. Here's the exact logging setup I use to catch them before users do.
Read →
Before & After: How I Rewrote My cPanel Caching Strategy and Cut Server Load by 60%
Development August 11, 2026
Before & After: How I Rewrote My cPanel Caching Strategy and Cut Server Load by 60%
Shared hosting doesn't have Redis or Varnish — but that doesn't mean you're stuck. Here's the exact caching rewrite that transformed a sluggish cPanel app.
Read →
The CSRF Token Bug That Only Broke in Safari: A Production Post-Mortem
Development July 29, 2026
The CSRF Token Bug That Only Broke in Safari: A Production Post-Mortem
A CSRF protection bug that only appeared in Safari on iOS cost me two days of debugging. Here's exactly what happened and how I fixed it.
Read →