Blog / Coding tips

PHP File Upload Validation (MIME, Size and Safe Names)

Secure PHP file upload validation means: reject anything that is not UPLOAD_ERR_OK, enforce your own byte limit, detect the real MIME type with finfo (never trust $_FILES['file']['type'] or the client filename), map that MIME to an allow-list extension, generate a new random name, and only then call move_uploaded_file(). Store files outside the web root when you can, or disable script execution in the upload directory. A .png extension on a PHP payload is not enough to stop an attack if you trust the browser.

I see the same shortcut in beginners’ code: check that the extension is jpg or png, then move the file into /public/uploads. That fails as soon as someone uploads shell.php.png or renames a PHP file. The examples below were run on PHP 8.4 with the fileinfo extension; the “evil” fixture is a text file pretending to be a PNG via the browser’s claimed type.

What $_FILES actually contains

A form needs method="post" and enctype="multipart/form-data":

<form method="post" action="/upload.php" enctype="multipart/form-data">
  <input type="file" name="avatar" accept="image/png,image/jpeg,image/webp" required>
  <button type="submit">Upload</button>
</form>

accept is a hint for the file picker only. It is not security.

For name="avatar", PHP fills $_FILES['avatar'] with roughly:

| Key | Meaning | Trust? | |-----|---------|--------| | name | Original filename from the OS | No — path tricks, .php, spaces | | type | MIME type claimed by the browser | No — fully attacker-controlled | | tmp_name | Temporary path on the server | Yes — PHP created it | | error | UPLOAD_ERR_* code | Yes — check first | | size | Size in bytes (as reported) | Cross-check with filesize() |

Always start with error. If it is not UPLOAD_ERR_OK (integer 0), stop and show a clear message. Common codes: UPLOAD_ERR_INI_SIZE, UPLOAD_ERR_FORM_SIZE, UPLOAD_ERR_PARTIAL, UPLOAD_ERR_NO_FILE.

A validation function (tested)

<?php
declare(strict_types=1);

function validate_upload(array $file, int $maxBytes = 1_048_576): array
{
    if (($file['error'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_OK) {
        $map = [
            UPLOAD_ERR_INI_SIZE   => 'File exceeds server limit.',
            UPLOAD_ERR_FORM_SIZE  => 'File exceeds form limit.',
            UPLOAD_ERR_PARTIAL    => 'Upload was incomplete.',
            UPLOAD_ERR_NO_FILE    => 'No file was uploaded.',
        ];
        return [$map[$file['error']] ?? 'Upload failed.'];
    }

    // Live code: require is_uploaded_file($file['tmp_name'])
    if (!is_file($file['tmp_name'])) {
        return ['Not a valid upload.'];
    }

    if ($file['size'] > $maxBytes || filesize($file['tmp_name']) > $maxBytes) {
        return ['File is too large (max 1 MB).'];
    }

    $finfo = new finfo(FILEINFO_MIME_TYPE);
    $mime = $finfo->file($file['tmp_name']);
    $allowed = [
        'image/jpeg' => 'jpg',
        'image/png'  => 'png',
        'image/webp' => 'webp',
    ];
    if ($mime === false || !isset($allowed[$mime])) {
        return ['Only JPEG, PNG or WebP images are allowed. Detected: ' . ($mime ?: 'unknown')];
    }

    $safeName = bin2hex(random_bytes(16)) . '.' . $allowed[$mime];
    return [
        'ok' => true,
        'mime' => $mime,
        'name' => $safeName,
        'client_name' => $file['name'],
    ];
}

Test results against three fixtures (browser claimed image/png for all):

=== ok.png (browser said image/png) ===
Array
(
    [ok] => 1
    [mime] => image/png
    [name] => b466241122ffc80d5163a2d8118d73ae.png
    [client_name] => ok.png
)
=== evil.php.png (browser said image/png) ===
Array
(
    [0] => Only JPEG, PNG or WebP images are allowed. Detected: text/x-php
)
=== note.txt (browser said image/png) ===
Array
(
    [0] => Only JPEG, PNG or WebP images are allowed. Detected: text/plain
)

Reading those results:

  • A real PNG passes; the stored name is random hex plus .png from the detected MIME, not from the client.
  • evil.php.png is detected as text/x-php (or similar) and rejected even though the name ends in .png and the browser lied.
  • A plain text note is text/plain and rejected the same way.

That is the whole point of finfo: it reads file magic bytes on the server.

Moving the file after validation

<?php
declare(strict_types=1);

$result = validate_upload($_FILES['avatar'] ?? []);
if (!isset($result['ok'])) {
    // show $result errors; redisplay form
    exit;
}

$destDir = '/var/app/storage/avatars'; // outside public/ if possible
$dest = $destDir . '/' . $result['name'];

if (!is_uploaded_file($_FILES['avatar']['tmp_name'])) {
    http_response_code(400);
    exit('Invalid upload.');
}

if (!move_uploaded_file($_FILES['avatar']['tmp_name'], $dest)) {
    http_response_code(500);
    exit('Could not save file.');
}

// Save $result['name'] in the database with the user id via PDO

is_uploaded_file / move_uploaded_file only accept paths PHP registered as uploads. That stops tricks where tmp_name is set to /etc/passwd in a forged request.

Generate names with random_bytes (or a UUID). Do not keep the user’s original filename as the on-disk name. If you need the original for download labels, store it in a separate database column after escaping it on output (htmlspecialchars).

php.ini limits you should know

Application checks are not enough if the request never reaches your script:

  • upload_max_filesize — per-file ceiling
  • post_max_size — whole POST body (must be larger than the file plus other fields)
  • max_file_uploads — how many files per request
  • file_uploads — must be On

When the body exceeds post_max_size, $_FILES and $_POST can be empty. Detect that by checking request method POST with empty $_POST and empty $_FILES and a large CONTENT_LENGTH. Surface a friendly “file too large” message.

MAX_FILE_SIZE hidden fields in HTML are advisory only; attackers ignore them. Keep your own $maxBytes check.

Where to store files

Best options, safest first:

  1. Outside the document root (for example /var/app/storage) and serve via a PHP script that checks the session (login sessions) and streams the file with a safe Content-Type.
  2. Object storage (S3-compatible) with a private bucket and signed URLs.
  3. Inside public, only if the directory cannot execute scripts. On Apache, something like php_flag engine off or handing files as static assets with no PHP handler. Still use random names and MIME allow-lists.

Never let uploads land in a folder where something.php would execute. Double extensions and null-byte tricks are less effective on modern PHP, but misconfigured servers still run scripts in upload dirs.

Images-only extras

If you only want images, after the MIME allow-list you can also:

<?php
$info = @getimagesize($tmp);
if ($info === false) {
    // not a decodeable image
}
// $info[0], $info[1] width/height; $info['mime']

Or re-encode with GD/Imagick (load then save as JPEG/PNG) to strip weird payloads hanging off polyglot files. That is heavier but appropriate for profile photos on a public site. For PDFs or office documents the threat model changes again — those need different parsers and usually should not be served as inline HTML.

Wiring validation into a form flow

Combine with normal field validation from PHP form validation and HTML input types:

  1. Require login if only members may upload.
  2. Check CSRF token on POST.
  3. Run validate_upload.
  4. Move file; insert row with PDO (prepared statements).
  5. Redirect (PRG pattern) so refresh does not re-POST the file.

Common mistakes

  1. Trusting $_FILES['type'] or the extension alone.
  2. Using the original filename on disk (../../web/shell.php style tricks, spaces, Unicode lookalikes).
  3. Skipping error === UPLOAD_ERR_OK.
  4. Calling move_uploaded_file before MIME checks.
  5. Uploading into a PHP-enabled public directory.
  6. No size limit in app code (relying only on php.ini).
  7. Showing raw client filenames in HTML without escaping.
  8. Allowing SVG as an “image” without understanding SVG can carry JavaScript.

FAQ

Why is $_FILES['type'] unreliable?

Because it is sent by the client. PHP does not verify it. Attackers set Content-Type: image/png on a PHP script. Always detect from the temp file with finfo.

Is checking the extension enough?

No. Extensions are cosmetic. evil.php.png or evil.jpg with PHP content can still hurt you if the server executes it or if another bug includes the file. MIME allow-lists plus safe storage matter more.

Do I need is_uploaded_file if I use move_uploaded_file?

move_uploaded_file already checks that the source is an uploaded file and returns false otherwise. An explicit is_uploaded_file before other processing (reading with finfo, getimagesize) is still good practice so you never analyse an arbitrary path.

What MIME types should I allow?

For avatars: image/jpeg, image/png, image/webp are a sensible default. Add image/gif only if you accept animation. Avoid image/svg+xml unless you sanitise SVG. For documents, prefer storing them non-executable and serving as Content-Disposition: attachment.

How do I test uploads locally?

Use PHP’s built-in server, a small form, and intentional bad files (rename a .php to .png, upload a huge file, omit enctype). Unit-test your validator by pointing tmp_name at fixtures and, in production code paths, still requiring is_uploaded_file.

Can I validate with JavaScript instead?

Client checks improve UX only. Anyone can bypass them with curl or a modified browser. Server-side validation is mandatory; see also the spirit of my fetch POST JSON note — the browser is not a security boundary.

Complete upload.php sketch

<?php
declare(strict_types=1);
session_start();
require __DIR__ . '/db.php';
require __DIR__ . '/upload_helpers.php'; // validate_upload()

if (empty($_SESSION['user_id'])) {
    header('Location: /login.php');
    exit;
}

$errors = [];
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
    $result = validate_upload($_FILES['avatar'] ?? []);
    if (!isset($result['ok'])) {
        $errors = $result;
    } else {
        $destDir = dirname(__DIR__) . '/storage/avatars';
        if (!is_dir($destDir) && !mkdir($destDir, 0750, true)) {
            $errors[] = 'Storage unavailable.';
        } else {
            $dest = $destDir . '/' . $result['name'];
            if (!move_uploaded_file($_FILES['avatar']['tmp_name'], $dest)) {
                $errors[] = 'Could not save file.';
            } else {
                $pdo->prepare(
                    'UPDATE users SET avatar_path = ? WHERE id = ?'
                )->execute([$result['name'], $_SESSION['user_id']]);
                header('Location: /profile.php?uploaded=1');
                exit;
            }
        }
    }
}

Serve the avatar through a small script that checks the session (or uses a non-guessable name plus long cache headers) rather than listing the storage directory. Escape any client-supplied original filename if you show it (XSS post).

Multiple files

When the input is name="photos[]", PHP nests arrays under each $_FILES key. Loop with an index, run validate_upload on each assembled single-file array, and stop at a maximum count (for example 5). One bad file should not leave half-moved orphans — validate all first, then move, or move to a temp staging area and commit.

Logging and abuse

Log the user id, detected MIME, size and final stored name (not the raw temp path forever). Rate-limit uploads per account to stop disk-fill attacks. Quotas belong next to your size check. Virus scanning (ClamAV etc.) is optional for trusted-size image sites and more important when you accept arbitrary documents.

Why random names beat “slugified” originals

Slugifying My Holiday🙂.PNG into my-holiday.png looks tidy but collides when two users upload the same name, and it still trusts that the extension matches content. Random hex names collide only with negligible probability and force the extension to come from your MIME map. Keep the pretty title in the database for UI labels.

Content-Disposition when downloading

If you store files privately and stream them:

<?php
declare(strict_types=1);
// after auth checks...
$path = $destDir . '/' . $safeBasename; // from DB, not from $_GET directly
$mime = (new finfo(FILEINFO_MIME_TYPE))->file($path) ?: 'application/octet-stream';
header('Content-Type: ' . $mime);
header('X-Content-Type-Options: nosniff');
header('Content-Disposition: attachment; filename="' . e($downloadLabel) . '"');
readfile($path);
exit;

nosniff stops some browsers from MIME-sniffing a file into executable HTML. Prefer attachment for untrusted documents. For images you own and validated, inline may be acceptable. Never take the filesystem path from the query string — take an id, look up the path server-side.

Staging and rollback

On failure after move_uploaded_file, delete the partial file. On database failure after a successful move, delete the file or you leak orphans. Wrap DB work in a transaction where you can; filesystems do not roll back with InnoDB, so compensate explicitly.

Error messages users understand

Map technical failures to plain UK English:

  • “Please choose a JPEG, PNG or WebP image under 1 MB.”
  • “The upload did not finish — try again on a stabler connection.”
  • “You must be signed in to upload an avatar.”

Log the MIME and error code server-side for yourself. Do not show text/x-php to end users in a way that teaches attackers what you detect; a generic “that file type is not allowed” is enough on the public form.

Avatars versus attachments

Avatars are small, image-only, publicly or semi-publicly readable. Attachments to a support ticket may be PDFs and need stricter private storage and staff-only download. Reuse validate_upload with different allow-lists and $maxBytes. One function with parameters beats two copy-pasted scripts that drift apart.

Checklist before you ship uploads

  1. enctype on the form.
  2. Auth required if appropriate.
  3. CSRF check on POST.
  4. UPLOAD_ERR_OK.
  5. Size check vs your constant and vs filesize.
  6. finfo allow-list.
  7. Random name + move_uploaded_file.
  8. DB row via PDO.
  9. Directory not executable.
  10. Download path does not take raw user paths.

Tick all ten and you are ahead of most tutorial code on the internet.

What about getimagesize alone?

getimagesize is a useful extra check for images, but it is not a full substitute for finfo allow-lists, and it does nothing for PDFs. Some polyglot files try to be both image and script; re-encoding with GD (imagecreatefromjpeg → imagejpeg) is the stronger image-only hardening step when you need it. Start with finfo + size + random names; add re-encoding when avatars are shown to other users.

Further reading

Check the error code, measure the size, believe finfo, rename the file, and keep executables out of the upload directory. That checklist removes most upload disasters in small PHP apps.

// note

How to read this note.

This is a learning note from studying the web. It is one small topic, written so I can remember it. It is not a course and not a claim that I have finished the subject.

If a sentence is wrong, say so from the contact page and name this title. Drafts never appear here. Related notes, when they exist, are other published posts, and the same sample rule applies to each of them.

Related notes