File Upload Security

Yo Yogesh.M Updated 17 Oct 2026
6 min read ·Lesson 76 of 109
In this post

Why Uploads Are Dangerous In simple terms: A file upload lets strangers place files on your server. If an attacker uploads a PHP script and can then open it in the browser, the server runs their code

Why Uploads Are Dangerous

In simple terms: A file upload lets strangers place files on your server. If an attacker uploads a PHP script and can then open it in the browser, the server runs their code — they can read the database password, change pages, or take over the server. Other dangerous uploads include HTML or SVG files that run JavaScript in your domain, oversized files that fill the disk, and malware passed on to other users.

Technically: Upload handling is a chain of decisions, and a weakness in any link can be exploited: trusting client-supplied names and MIME types, storing files where the web server executes them, serving user files with dangerous content types from your own origin, missing size and count limits, and unsafe processing (image libraries, archive extraction). Secure handling applies defense in depth: validate, rename, store safely, serve safely, and limit resources.

Threats and Countermeasures

ThreatExampleCountermeasure
Remote code executionshell.php (or shell.php.jpg, shell.phtml) uploaded and requested via URLStore outside the web root; disable script execution in upload folders; never keep the client's file name
MIME and extension spoofingA PHP file renamed to photo.jpg with Content-Type: image/jpegDetect the real type with finfo; derive the extension from the detected type
Stored XSSAn .html or .svg file containing <script> opened from your domainDo not allow HTML/SVG uploads (or sanitize them); serve with safe headers; use a separate domain for user content
Path traversalFile name ../../index.phpNever use the client's name in paths
Denial of serviceHuge files, thousands of uploads, decompression bombsSize and count limits, quotas, dimension checks, rate limiting
Malware distributionInfected documents shared with other usersAntivirus scanning; restrict file types

The Secure Upload Pipeline

1. Check the upload error code and size
2. Detect the real MIME type from the content (finfo)
3. Allow only expected types; choose the extension from the detected type
4. Process if possible (re-encode images)
5. Generate a random file name
6. Move to private storage outside the web root (move_uploaded_file)
7. Record metadata in the database
8. Serve through a script with safe headers and permission checks

Choosing Extensions From Detected Types

<?php
const ALLOWED_UPLOAD_TYPES = [
    'image/jpeg' => 'jpg',
    'image/png' => 'png',
    'image/webp' => 'webp',
    'application/pdf' => 'pdf',
];

foreach (['image/jpeg', 'application/pdf', 'image/svg+xml', 'text/html', 'application/x-php'] as $detected) {
    echo str_pad($detected, 18) . '→ ' . (ALLOWED_UPLOAD_TYPES[$detected] ?? 'rejected') . "\n";
}

$storedName = bin2hex(random_bytes(16)) . '.' . ALLOWED_UPLOAD_TYPES['image/jpeg'];
var_dump(preg_match('/^[a-f0-9]{32}\.jpg$/', $storedName));

Output

image/jpeg        → jpg
application/pdf   → pdf
image/svg+xml     → rejected
text/html         → rejected
application/x-php → rejected
int(1)

The client's file name (shell.php.jpg, ../../index.php, invoice.pdf) plays no role in where or how the file is stored. The stored name is 32 random hex characters plus an extension chosen by the server — it cannot contain .php, path separators, or anything else the attacker controls.


A Complete Upload Handler

<?php
function storeUpload(array $file, string $storageDir, int $maxBytes): array
{
    if (!isset($file['error']) || is_array($file['error'])) {
        return ['ok' => false, 'error' => 'Invalid upload.'];
    }
    if ($file['error'] !== UPLOAD_ERR_OK) {
        return ['ok' => false, 'error' => 'The upload failed. Please try again.'];
    }
    if ($file['size'] <= 0 || $file['size'] > $maxBytes) {
        return ['ok' => false, 'error' => 'The file is empty or too large.'];
    }
    if (!is_uploaded_file($file['tmp_name'])) {
        return ['ok' => false, 'error' => 'Invalid upload.'];
    }

    $mime = (new finfo(FILEINFO_MIME_TYPE))->file($file['tmp_name']);
    $extension = ALLOWED_UPLOAD_TYPES[$mime] ?? null;

    if ($extension === null) {
        return ['ok' => false, 'error' => 'This file type is not allowed.'];
    }

    $storedName = bin2hex(random_bytes(16)) . '.' . $extension;
    $target = rtrim($storageDir, '/') . '/' . $storedName;

    if (!move_uploaded_file($file['tmp_name'], $target)) {
        return ['ok' => false, 'error' => 'The file could not be saved.'];
    }

    chmod($target, 0640);

    return [
        'ok' => true,
        'stored_name' => $storedName,
        'mime' => $mime,
        'size' => $file['size'],
        'original_name' => mb_substr(basename((string) $file['name']), 0, 150),
    ];
}

What Each Step Prevents

  1. Error and structure checks reject failed uploads and attempts to send arrays where a single file is expected.
  2. Size limits prevent disk exhaustion; keep upload_max_filesize and post_max_size in php.ini reasonable as well.
  3. is_uploaded_file() / move_uploaded_file() guarantee the file really came from this HTTP upload, not from a path an attacker supplied.
  4. finfo detects the type from the file's content; the browser's $file['type'] is ignored because the client controls it.
  5. Random names and server-chosen extensions prevent code execution, overwriting, and path traversal.
  6. chmod(0640) makes the file readable by its owner and group only, and never executable.
  7. The original name is kept only as metadata (shortened, without path), and must be escaped whenever displayed.

For images, go one step further and re-encode them with GD or Imagick (see PHP File Upload). Re-encoding strips embedded payloads — such as PHP code hidden in image metadata ("polyglot" files) — and private EXIF data.


Storing Files Where Code Cannot Run

Best: Outside the Web Root

/var/www/shop/
├── public/          ← web server document root
│   └── index.php
└── storage/
    └── uploads/     ← not reachable by URL at all

Files outside public/ cannot be requested directly; a PHP script serves them after checking permissions.

If Uploads Must Be Inside the Web Root

Disable script execution for the upload directory in the web server:

# Nginx
location ^~ /uploads/ {
    location ~ \.(php|phtml|phar)$ {
        deny all;
    }
}
# Apache (.htaccess in the uploads folder)
<FilesMatch "\.(php|phtml|phar|php[0-9])$">
    Require all denied
</FilesMatch>

These rules are a second layer. Random names with safe extensions remain the primary defense.


Serving Uploaded Files Safely

<?php
// download.php?id=42 — after checking that the current user may access file 42
header('Content-Type: ' . $file['mime']);
header('Content-Length: ' . filesize($path));
header('X-Content-Type-Options: nosniff');
header("Content-Security-Policy: default-src 'none'; sandbox");

$inline = str_starts_with($file['mime'], 'image/');
$filename = preg_replace('/[^A-Za-z0-9._-]/', '_', $file['original_name']);
header('Content-Disposition: ' . ($inline ? 'inline' : 'attachment') . '; filename="' . $filename . '"');

readfile($path);
HeaderPurpose
Content-TypeThe detected type from upload time, never guessed from the name
X-Content-Type-Options: nosniffStops browsers from "sniffing" the content and treating a file as HTML
Content-Security-Policy: …; sandboxEven if a file is rendered, it cannot run scripts or access your origin
Content-Disposition: attachmentDownloads documents instead of displaying them in the browser

Large platforms serve user content from a separate domain (for example, usercontent.example), so any script that slips through cannot access the main site's cookies or pages.


Additional Protections

MeasurePurpose
Limit max_file_uploads and request sizesPrevent mass uploads in one request
Per-user quotas and rate limitingPrevent storage abuse
Antivirus scanning (for example, ClamAV)Detect known malware before sharing files
Image dimension limitsPrevent decompression bombs when processing
Safe archive handlingValidate entry paths when extracting ZIP files ("Zip Slip"), and limit extracted size
Authentication for uploadsReduce anonymous abuse

Common Mistakes

Keeping the Client's File Name

Effect: Code execution (.php), overwriting, path traversal.

Fix: Generate random names; choose extensions from the detected type.

Trusting $_FILES['file']['type'] or the Extension

Effect: Malicious files pass validation.

Fix: Detect the type with finfo and use an allow-list.

Storing Uploads in an Executable Web Directory

Effect: Uploaded scripts can be run via URL.

Fix: Store outside the web root, or disable execution for the folder.

Allowing HTML or SVG Uploads From Users

Effect: Stored XSS on your domain.

Fix: Disallow them, sanitize them, or serve them from a separate sandboxed domain.

No Limits

Effect: Full disks and slow servers.

Fix: Enforce size, count, dimension, and quota limits.


Best Practices

  • Validate uploads by content, with an allow-list of types and server-chosen extensions.
  • Rename every file randomly and store it outside the web root with restrictive permissions.
  • Re-encode images and scan documents where possible.
  • Serve files through a script with correct Content-Type, nosniff, a sandboxing CSP, and Content-Disposition.
  • Limit sizes, counts, and quotas, and check permissions before every download.

Official reference: https://www.php.net/manual/en/features.file-upload.common-pitfalls.php

0 Comments

Reviewed before they appear

No comments yet.

PHP
PHP Home PHP Switch PHP Match Alternative Syntax PHP Loops While Loop Do While Loop For Loop Foreach Loop Nested Loops Break Statement PHP Home PHP Intro PHP Install PHP Built-in Web Server PHP Syntax PHP Tags PHP and HTML PHP Comments PHP Multiline Comments PHP Variables Variables Scope PHP Casting Logical Operators Null Coalescing Operator Spaceship Operator Operator Precedence Continue Statement Modify Strings PHP If...Else...Elseif PHP If Modify Strings PHP If Operators PHP Functions Concatenate Strings PHP If...Else Slicing Strings User-defined Functions Slicing Strings PHP Shorthand if Escape Characters Default Argument Values PHP Nested if Pass by Reference String Interpolation Named Arguments PHP File Upload Variadic Functions Return Values Search in Strings Multibyte Strings Type Declarations in Functions PHP Numbers PHP Math PHP Floats PHP Random Numbers PHP Constants PHP Properties PHP Methods PHP Constructor Constructor Property Promotion PHP Cookies PHP Destructor PHP Access Modifiers Readonly Properties PHP Sessions PHP Inheritance Method Overriding PHP Filters The final Keyword CSRF Protection PHP Filters Advanced Session Security Secure Cookies PHP Magic Constants File Upload Security PHP Callback Functions PHP Operators Filesystem Security PHP JSON Arithmetic Operators Error Reporting in Production Hiding PHP Assignment Operators PHP Configuration (php.ini) Comparison Operators PHP Command Line (CLI) PHP Exceptions Increment / Decrement Operators Migrating to PHP 8 PHP Form Validation PHP Form Required PHP Form URL/E-mail Error Handling Form Input Sanitizing Escaping Output (htmlspecialchars) Custom Error Handler Checkbox, Radio and Select Inputs Sticky Forms PHP Form Complete PHP Date and Time Date Formatting Error Logging HTTP Headers and Redirects Output Buffering Sending Email PHP cURL Consuming REST APIs PHP Generators
Ask about this post
AI Ask about this post

Ask questions about File Upload Security and get answers drawn from it.

Signed-in readers only.