Skip to content
ExponaBLOG
PHP & LARAVEL DEVELOPMENT · DAY 047 / 100

Uploading files securely in PHP: lessons from production

File uploads are a common attack vector.

Expona Admin7 min read

Uploading files securely in PHP: lessons from production

We've all seen it: a user avatar upload feature that seemed straightforward, then suddenly your server is hosting malware. File uploads are one of the most dangerous features we build, yet we often rush through them thinking validation is just checking file extensions. After years handling production systems across e-commerce and SaaS platforms, we've learned that uploading files securely in PHP requires multiple layers of defense, each closing a specific attack vector.

Why file uploads matter

File upload vulnerabilities consistently rank in the OWASP top 10. An attacker can upload a PHP shell disguised as an image, execute arbitrary code on your server, and own your infrastructure. They can exhaust disk space, bypass authentication systems, or distribute malware to your users. We're not being paranoid—this happens constantly.

The challenge is that validation seems simple on the surface but has dozens of subtle gotchas. We'll walk through each layer you need in production, with real code you can adapt today.

Validate MIME type and extension

Most developers start here, and it's essential—but insufficient on its own. MIME type validation has two common forms: checking the file extension and checking the file's actual MIME type.

Extension-only validation is weak. An attacker uploads shell.php.jpg, the server strips the extension, or misconfigures Apache to execute .php files inside image directories. Never trust the extension alone.

MIME type checking via $_FILES['upload']['type'] is worse—it's set by the client and trivial to spoof. Always validate on the server using the file's actual contents.

php
// Using finfo_file to detect actual MIME type
$finfo = finfo_open(FILEINFO_MIME_TYPE);
$mimeType = finfo_file($finfo, $_FILES['upload']['tmp_name']);
finfo_close($finfo);

$allowedTypes = ['image/jpeg', 'image/png', 'image/gif', 'application/pdf'];

if (!in_array($mimeType, $allowedTypes, true)) {
    throw new Exception('Invalid file type: ' . $mimeType);
}

// Also validate extension matches MIME type
$ext = strtolower(pathinfo($_FILES['upload']['name'], PATHINFO_EXTENSION));
$allowedExtensions = ['jpg', 'jpeg', 'png', 'gif', 'pdf'];

if (!in_array($ext, $allowedExtensions, true)) {
    throw new Exception('Invalid file extension');
}

In production, we've also used Symfony's MIME component, which is battle-tested and regularly updated for new attack patterns:

php
use Symfony\Component\Mime\MimeTypes;

$mimeTypes = new MimeTypes();
$mimeType = $mimeTypes->guessMimeType($_FILES['upload']['tmp_name']);

if (!in_array($mimeType, $allowedTypes, true)) {
    throw new Exception('Invalid MIME type');
}

But here's the real lesson from production: even perfect MIME validation isn't enough. Move to the next layer immediately.

Limit file size

We've had to respond to incidents where an attacker uploaded enormous files to exhaust disk space, crash the server, or trigger denial-of-service conditions. Validate file size both on the client and server.

Client-side validation (via HTML5) improves user experience but provides zero security. An attacker bypasses it entirely. Your server-side check is what matters.

php
$maxFileSize = 5 * 1024 * 1024; // 5 MB

if ($_FILES['upload']['size'] > $maxFileSize) {
    throw new Exception('File exceeds maximum size of 5 MB');
}

// Also check actual file size in case size reporting is manipulated
if (filesize($_FILES['upload']['tmp_name']) > $maxFileSize) {
    throw new Exception('Actual file size exceeds limit');
}

Configure PHP to enforce limits at the system level as well. In your php.ini:

php
upload_max_filesize = 10M
post_max_size = 10M

These act as a safety net. Even if your application code has a bug, the server won't accept uploads beyond these thresholds.

Store outside the web root or in S3

This is where your security architecture changes fundamentally. The worst case is storing uploads in a web-accessible directory where the server executes them as code. We've seen this turn into complete compromise in minutes.

The best practice: store uploads outside your web root entirely. If your web root is /var/www/html, store uploads in /var/uploads or similar. This way, even if an attacker uploads a PHP file, the web server can't execute it because it's not in the publicly served directory.

php
// Bad: storing in public directory
$uploadDir = '/var/www/html/uploads/';

// Good: storing outside web root
$uploadDir = '/var/uploads/';

// Verify the path is outside web root
$webRoot = realpath($_SERVER['DOCUMENT_ROOT']);
$uploadPath = realpath($uploadDir);

if (strpos($uploadPath, $webRoot) === 0) {
    throw new Exception('Upload directory must be outside web root');
}

In modern production systems, we typically use cloud storage. AWS S3, Google Cloud Storage, and Azure Blob Storage handle scale, durability, and security better than local filesystems. They also prevent code execution by default.

php
use Aws\S3\S3Client;

$s3 = new S3Client([
    'version' => 'latest',
    'region'  => 'us-east-1'
]);

$s3->putObject([
    'Bucket' => 'my-app-uploads',
    'Key'    => $randomFileName,
    'Body'   => fopen($_FILES['upload']['tmp_name'], 'r'),
    'ACL'    => 'private', // Never make uploads public
]);

unlink($_FILES['upload']['tmp_name']); // Clean up temp file

If you must store files locally, configure your web server to never execute code in that directory. For Nginx, add this to your server block:

php
location /uploads/ {
    location ~ \.php$ {
        deny all;
    }
}

For Apache, place a .htaccess file in the uploads directory:

php
php_flag engine off
AddType text/plain .php .php3 .php4 .php5 .php7 .phtml

Generate random file names

Never, ever store uploaded files with their original names. An attacker can overwrite existing files, perform path traversal attacks, or guess URLs to access files they shouldn't see.

php
// Bad: using original filename
$uploadedFileName = $_FILES['upload']['name'];
$finalPath = $uploadDir . $uploadedFileName;

// Good: generate random name and preserve only the extension
$ext = strtolower(pathinfo($_FILES['upload']['name'], PATHINFO_EXTENSION));
$randomName = bin2hex(random_bytes(32)) . '.' . $ext;
$finalPath = $uploadDir . $randomName;

Store a mapping in your database linking the random filename to metadata like original filename, upload timestamp, and user ID:

php
$stmt = $pdo->prepare('
    INSERT INTO file_uploads (user_id, random_name, original_name, mime_type, size, uploaded_at)
    VALUES (?, ?, ?, ?, ?, NOW())
');

$stmt->execute([
    $userId,
    $randomName,
    $_FILES['upload']['name'],
    $mimeType,
    $_FILES['upload']['size']
]);

$fileId = $pdo->lastInsertId();

When users request files, look them up by ID or a signed token, never by filename. This prevents enumeration attacks where an attacker guesses filenames to access other users' data.

Common mistakes

  • Relying only on extension checking: Extensions are trivial to spoof. Always validate actual MIME type and file contents.
  • Trusting $_FILES['type']: This is client-supplied and completely unreliable. Use finfo_file or a library instead.
  • Storing files in web-accessible directories with dangerous extensions: If you store in the web root, ensure your server can't execute code there.
  • Using predictable filenames: Sequential IDs or timestamps let attackers enumerate files. Use cryptographically secure randomness.
  • Skipping server-side size validation: Client-side limits are for UX only. Always validate size server-side.
  • Forgetting to clean up temporary files: PHP's tmp_name files should be deleted after processing or moved to permanent storage.
  • Not validating after moving files: Validate the uploaded file while it's still in the temp directory, before storing it permanently.

Key takeaways

  • File uploads are a critical attack surface—treat them with the same rigor as authentication or database queries.
  • Validate MIME type using server-side detection like finfo_file, never trust client-supplied MIME type or extension alone.
  • Enforce file size limits both in your application and via PHP configuration.
  • Store uploads outside your web root or in cloud storage to prevent code execution.
  • Use cryptographically random filenames and never expose the original filename in URLs.
  • Map files to users in your database so you can enforce access control and prevent enumeration.
  • Configure your web server to block code execution in upload directories as a defense-in-depth layer.
  • Test your upload handling with intentionally malicious files—don't assume validation works until you verify it.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles