Skip to content
Carl Victor Fontanos
Carl Victor Fontanos

Carl Victor Fontanos

Software Engineer

I build web applications and share what I learn along the way.

© 2026

Never Trust a File Extension: Real MIME Detection in PHP

C
Carlo Fontanos
· 2 min read

Here's an unpleasant demo. Take shell.php, rename it to vacation.jpg, upload it to a form that checks the extension and the $_FILES type field. Both checks pass. The extension is whatever the attacker typed, and $_FILES['upload']['type'] is sent by the client - it's literally a request header wearing a trustworthy name.

Read the bytes instead

The fileinfo extension inspects actual file content - magic numbers, structure - the way the file command does:

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

$allowed = [
    'image/jpeg' => 'jpg',
    'image/png'  => 'png',
    'image/webp' => 'webp',
    'application/pdf' => 'pdf',
    'application/zip' => 'zip',
];

if (!isset($allowed[$realType])) {
    http_response_code(422);
    exit('File type not allowed.');
}

The renamed PHP script comes back as text/x-php no matter what the filename claims. Note the allowlist shape: you enumerate what's acceptable, never blocklist what's scary - blocklists lose to the file type you didn't think of.

The full layer cake

Content sniffing is the load-bearing layer, not the only one:

// 1. Never use the client's filename for anything but display
$ext  = $allowed[$realType];                      // extension derived from REAL type
$name = bin2hex(random_bytes(16)) . '.' . $ext;   // generated, collision-proof name

// 2. Move outside the webroot (or to a no-execute location)
move_uploaded_file($_FILES['upload']['tmp_name'], UPLOAD_DIR . $name);

// 3. For images: re-encoding is the strongest sanitizer there is
$img = imagecreatefromstring(file_get_contents(UPLOAD_DIR . $name));
if ($img === false) { unlink(UPLOAD_DIR . $name); exit('Corrupt image.'); }
imagejpeg($img, UPLOAD_DIR . $name, 85);   // rebuilt pixels, payloads gone

Why each layer: generated names kill path traversal and the double-extension trick (photo.php.jpg); storing outside the webroot means even a smuggled script is never executable by URL (serve files through a PHP readfile endpoint or an aliased static path with PHP disabled); re-encoding images destroys anything hiding in EXIF or appended to the file, because you're saving new pixels, not the original bytes.

Loose ends worth knowing

  • SVG is an XML document that can carry scripts - treat it as code, not as an image. Don't accept it from untrusted users unless you sanitize it properly.
  • finfo can misread some formats (CSVs are notoriously fuzzy - they're just text). For text formats, validate structure yourself instead.
  • Check $_FILES['upload']['error'] === UPLOAD_ERR_OK and enforce size limits before any of this runs.

The same paranoia applies to pasted-screenshot uploads - the transport being fancy doesn't make the bytes trustworthy. Validate content, generate names, store dumb. That trio has never let me down.

C
Written by Carlo Fontanos

Full-stack web developer sharing practical tutorials and building tools that ship.

Got something on your mind?

My inbox is open - no forms disappearing into the void here.

  • Just say hello Found a tutorial useful? Spotted a mistake? Tell me.
  • Hire me for a project Have something custom in mind? Let's talk scope and timelines.
  • Product support Bought something here? I'll help you get it running.

I usually reply within 1-2 business days.

Message sent!

Your details are only used to reply to you.

Keep reading