MIME Types for Secure File Upload Validation

Every upload arrives with a MIME type, and it is the least trustworthy thing about the file: the browser guessed it from the file name, or the client simply typed it. A safe upload handler treats it as a hint, detects the type from the bytes on the server, checks it against a short allow-list, and stores the file where it can never run.

The three file types you may be comparing

An upload carries up to three type signals. The part's Content-Type comes from the client. The extension is the tail of a file name the user picked. The detected type comes from your server reading the bytes. The first two cost an attacker nothing to fake; the third costs a little more, and it still is not proof.

POST /avatar HTTP/1.1
Content-Type: multipart/form-data; boundary=upload

--upload
Content-Disposition: form-data; name="avatar"; filename="photo.jpg"
Content-Type: image/jpeg

...bytes chosen by the sender...

Nothing stops a script from naming an HTML page photo.jpg and declaring image/jpeg: curl -F 'avatar=@page.html;type=image/jpeg;filename=photo.jpg' does it in one line. In the browser, File.type is the operating system's guess from the extension, and an empty string means it had none. Client-side checks give friendlier error messages; the server repeats every one of them.

Compare types in lowercase and without parameters. Image/PNG and image/png are the same type, and a value such as text/plain; charset=utf-8 must be reduced to text/plain before it is checked against the allow-list. Most detectors, including PHP's finfo with FILEINFO_MIME_TYPE, already return the bare type.

Use a narrow allow-list, not a block-list

Start from what the feature needs. An avatar endpoint needs JPEG, PNG and WebP and has no business accepting SVG, PDF or ZIP. A block-list of dangerous types always misses something, a new extension or an alias nobody thought of, while an allow-list fails closed: anything unexpected is refused.

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

$detected = (new finfo(FILEINFO_MIME_TYPE))->file($tmpPath);
if (!array_key_exists($detected, $allowed)) {
    throw new RuntimeException('Unsupported upload type');
}

Use canonical values in the policy but expect real-world aliases at integration boundaries. JPEG is image/jpeg, not image/jpg; JavaScript modules are commonly served as text/javascript; CSV is normally text/csv. Do not silently map a completely unknown value to application/octet-stream and then accept it: that value means arbitrary binary data, not a safe generic document.

Give each endpoint its own list. A support-ticket form may reasonably take PDFs and ZIP files; if the profile-photo endpoint shares that list, you are suddenly running a PDF parser on avatars. Fewer allowed types means fewer parsers exposed to hostile input.

Detect content on the server

libmagic is the usual detector behind the scenes: finfo in PHP, python-magic in Python, the file command in a shell. Running file --mime-type on the exact temporary file your application received is also the quickest way to debug a rejected upload.

file --brief --mime-type upload.bin
# image/png

xxd -l 16 upload.bin
# 00000000: 8950 4e47 0d0a 1a0a ...

Magic bytes catch the lazy attack, a renamed executable or HTML page. They do not prove the rest of the file is valid: a file can start with a perfect PNG signature and carry anything after it, and polyglot files are built to satisfy two parsers at once. Where it matters, decode the file with a maintained library, reject anything that fails, and re-encode what you accept into a new file.

from PIL import Image

with Image.open(upload_path) as image:
    image.verify()                 # validate structure without trusting .type

with Image.open(upload_path) as image:
    image.convert('RGB').save(output_path, 'JPEG', quality=88)

Re-encoding images also removes most appended payloads and metadata, although it is not a substitute for keeping image libraries patched. For documents and archives, use a parser appropriate to that format rather than assuming one shared content-sniffer understands every internal constraint.

Require the extension and detected type to agree

The extension still matters, because web servers, download dialogs and desktop apps decide what to do from it. After detection, either generate the extension from the detected type or check that the supplied one is allowed for that type. invoice.pdf.php looks like a PDF at first glance, but only the last extension counts, and it says PHP.

$original = $upload->getClientOriginalName();
$extension = strtolower(pathinfo($original, PATHINFO_EXTENSION));
$detected = (new finfo(FILEINFO_MIME_TYPE))->file($upload->getRealPath());

if (!isset($allowed[$detected]) ||
    !in_array($extension, $allowed[$detected], true)) {
    throw new RuntimeException('File extension does not match its content');
}

A mismatch is either a user who renamed a file or someone probing you, and a clear rejection beats guessing. If the product does not care about names, drop the original extension altogether and derive a new one from the decoded format.

Do not use the original filename as a storage path. It can contain separators, control characters, Unicode lookalikes or names that collide on a case-insensitive filesystem. Generate a random identifier and keep the display name as escaped metadata only.

Validate dimensions, size and decompression cost

A correct MIME type can still carry a denial-of-service. Enforce the request body limit before buffering the upload, then a per-file limit after multipart parsing. For images, cap decoded width, height and total pixels, because a small compressed file can decode to gigabytes of pixel buffer; Pillow, for example, warns above about 89 million pixels and refuses images of more than twice that. For archives, cap the number of entries, the total unpacked size and the nesting depth.

const MAX_BYTES = 5 * 1024 * 1024;
const allowed = new Set(['image/jpeg', 'image/png', 'image/webp']);

if (file.size > MAX_BYTES) throw new Error('Image exceeds 5 MiB');
if (!allowed.has(file.type)) throw new Error('Choose JPEG, PNG or WebP');
// User feedback only: repeat and strengthen these checks on the server.

Read dimensions from a decoder rather than trusting EXIF fields or request parameters. Apply timeouts and memory limits to conversion jobs. Antivirus scanning can be useful for general attachments, but a clean scan does not make an active format safe to render inline and cannot replace format validation.

Reject password-protected archives if the service cannot inspect them. Avoid recursively unpacking user-controlled archives in a web request; isolate that work and prevent entries such as ../../app.php from escaping the extraction directory.

Store uploads so they cannot execute

Validation will eventually miss something; storage decides how bad that is. Keep untrusted files outside the web root or in object storage, under random keys, where no script handler runs, and serve them through your application or from a separate domain. A PHP file that slips past validation is harmless in a bucket that nothing executes.

HTTP/1.1 200 OK
Content-Type: application/pdf
Content-Disposition: attachment; filename="report.pdf"
X-Content-Type-Options: nosniff
Content-Security-Policy: sandbox

...validated bytes...

Set the response Content-Type from stored server metadata, never from a query parameter. X-Content-Type-Options: nosniff tells browsers not to reinterpret a response as HTML or script. Use Content-Disposition: attachment for formats that users need to download rather than display, particularly content with active features.

SVG deserves a deliberate decision. It is an image MIME type, but SVG is XML and may contain scripts, links and external references. If you only need raster avatars, exclude it. If you need SVG, sanitise it with an SVG-specific library and serve it under a restrictive policy from an isolated origin.

A practical upload validation sequence

Run the cheap checks before the expensive ones, and publish the file only after the last one passes:

1. Limit request and file size
2. Ignore the supplied path; retain display name as metadata
3. Detect MIME type from temporary-file bytes
4. Require an endpoint-specific allow-list
5. Decode/parse and enforce format limits
6. Re-encode or scan when the use case warrants it
7. Store under a random key outside the web root
8. Serve with fixed headers and nosniff

Log the declared type, detected type, extension, size and rejection reason, but do not log file contents or sensitive filenames unnecessarily. These fields reveal detector changes and client-specific problems without retaining hostile payloads in application logs.

Test disagreement cases explicitly: valid content with the wrong extension, a valid extension with random bytes, oversized dimensions, double extensions, empty files and a truncated otherwise-valid format. The successful path alone says almost nothing about whether an upload boundary is safe.

Frequently asked questions

Can I trust the MIME type sent with a file upload?

No. The multipart `Content-Type` is supplied by the client and can be changed freely. Use it for early feedback, then detect the type from bytes on the server and validate the complete format where the risk warrants it.

Should I validate a file by MIME type or extension?

Use both, and require them to agree with an endpoint-specific allow-list. The extension is user-controlled and byte detection is imperfect, so sensitive formats should also be opened by a real parser or decoder.

What MIME type should I allow for any file?

There is no safe MIME type meaning any acceptable file. `application/octet-stream` means arbitrary binary data; accepting it bypasses type restrictions. Define the exact formats the feature needs instead.

Are magic bytes enough to validate an upload?

No. They identify many renamed files, but a valid signature can precede malformed, appended or polyglot content. Parse the complete file and, for images, consider decoding and re-encoding it.

Is SVG safe to accept as an image upload?

Not by default. SVG can contain scripts, links and external references because it is an XML document, not a passive bitmap. Exclude it unless required; otherwise use a dedicated sanitiser and serve it from an isolated origin with restrictive headers.

Where should uploaded files be stored?

Outside the executable web root or in object storage, under server-generated random names. Serve them with a server-selected `Content-Type`, `X-Content-Type-Options: nosniff`, and attachment disposition where inline rendering is unnecessary.