DEV Community

Cover image for Secure File and Photo Upload Validation in PHP: Beyond Checking the Extension
Muhammad Ikhwan Fathulloh
Muhammad Ikhwan Fathulloh

Posted on

Secure File and Photo Upload Validation in PHP: Beyond Checking the Extension

File upload is a feature present in almost every web application, from document uploads and profile photos to report attachments. Unfortunately, it's also one of the most common entry points for attacks: web shells disguised as images, HTML files containing malicious scripts, or files deliberately oversized to overload the server.

This article covers a more thorough approach to validating file and photo uploads in PHP, drawn from experience building an upload helper in a Laravel project. The code examples here are simplified to avoid depending on any specific storage service, so they can be adapted to local disk, S3, or any other storage backend.

Why Extension Validation Alone Isn't Enough

A common pattern looks like this:

$extension = $file->getClientOriginalExtension();
if (!in_array($extension, ['jpg', 'png', 'pdf'])) {
    // reject
}
Enter fullscreen mode Exit fullscreen mode

The problem is that getClientOriginalExtension() reads the filename sent by the user's browser. This filename can be manipulated entirely. A file named shell.jpg could actually contain PHP code. The extension is just a label, not proof of the file's actual content.

Layers of Validation to Combine

Secure upload validation shouldn't rely on a single check. It works best as several complementary layers.

1. Validate MIME Type from File Content, Not from the Client

Use a function that reads the file's opening bytes (magic numbers), not the header sent by the browser.

function getRealMimeType(string $path): string
{
    $finfo = finfo_open(FILEINFO_MIME_TYPE);
    $mime = finfo_file($finfo, $path);
    finfo_close($finfo);

    return $mime;
}

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

if (!in_array(getRealMimeType($file->getPathname()), $allowedMimes)) {
    // reject
}
Enter fullscreen mode Exit fullscreen mode

With finfo, file type detection is based on actual content, so a file with a swapped extension alone won't pass.

2. Limit File Size Up Front

Besides saving resources, a size limit also prevents denial-of-service attacks through oversized files.

$maxFileSize = 2 * 1024 * 1024; // 2 MB

if ($file->getSize() > $maxFileSize) {
    // reject, and state the maximum limit in the error message
}
Enter fullscreen mode Exit fullscreen mode

Ideally this limit should also be enforced at the web server level (e.g. client_max_body_size in Nginx or upload_max_filesize in php.ini), not just in application code.

3. Scan Content for Dangerous Patterns

For files like images that shouldn't contain any executable code, a simple check for suspicious patterns can serve as an additional layer.

$content = file_get_contents($file->getPathname());
$dangerousPatterns = ['<?php', '<script', '<html'];

foreach ($dangerousPatterns as $pattern) {
    if (stripos($content, $pattern) !== false) {
        // reject
    }
}
Enter fullscreen mode Exit fullscreen mode

Note that this is not a substitute for antivirus or malware scanning, only a cheap additional filter for the most common cases. For production needs with higher stakes, integrating ClamAV or a third-party scanning service is recommended.

4. Never Trust the Original Filename

A filename from the user can contain path traversal characters like ../../etc/passwd or characters that cause problems on certain filesystems. Always generate a new filename on the server under controlled conditions.

function generateSafeFilename(string $originalName): string
{
    $extension = pathinfo($originalName, PATHINFO_EXTENSION);
    $safeName = preg_replace('/[^a-zA-Z0-9_-]/', '_', pathinfo($originalName, PATHINFO_FILENAME));

    return time() . '_' . strtolower($safeName) . '.' . strtolower($extension);
}
Enter fullscreen mode Exit fullscreen mode

The final filename should be built from a combination of a timestamp, a random identifier, or a user ID, never copied raw from user input.

5. Store Outside the Web Root, Serve Through Application Code

Uploaded files shouldn't be stored directly in a publicly accessible directory. Store them outside public/, then create a dedicated endpoint that verifies access rights before serving the file.

Route::get('/files/{id}', function ($id) {
    $file = File::findOrFail($id);

    abort_unless(auth()->user()->can('view', $file), 403);

    return response()->file(storage_path('app/private/' . $file->path));
});
Enter fullscreen mode Exit fullscreen mode

This approach prevents a file that passed validation from still being directly executable by the web server, since the server never serves it as a static path.

6. Use a Whitelist, Not a Blacklist

Instead of rejecting a list of known-dangerous file types (which can easily be incomplete), explicitly define which types are allowed.

$allowedExtensions = ['jpg', 'jpeg', 'png', 'pdf'];
$allowedMimes = ['image/jpeg', 'image/png', 'application/pdf'];
Enter fullscreen mode Exit fullscreen mode

A whitelist approach is far safer because it defaults to rejecting anything unrecognized, rather than only rejecting what's already known to be harmful.

Putting the Layers Together

Here's a validation function that combines all the points above:

function validateUpload($file, array $config): array
{
    $extension = strtolower($file->getClientOriginalExtension());
    if (!in_array($extension, $config['allowed_extensions'])) {
        return ['valid' => false, 'message' => 'File extension is not allowed.'];
    }

    $realMime = getRealMimeType($file->getPathname());
    if (!in_array($realMime, $config['allowed_mimes'])) {
        return ['valid' => false, 'message' => 'File type does not match its actual content.'];
    }

    if ($file->getSize() > $config['max_size']) {
        return ['valid' => false, 'message' => 'File size exceeds the maximum limit.'];
    }

    $content = file_get_contents($file->getPathname());
    foreach ($config['dangerous_patterns'] as $pattern) {
        if (stripos($content, $pattern) !== false) {
            return ['valid' => false, 'message' => 'File contains disallowed content.'];
        }
    }

    return ['valid' => true, 'filename' => generateSafeFilename($file->getClientOriginalName())];
}
Enter fullscreen mode Exit fullscreen mode

Closing Thoughts

Secure upload validation isn't about adding a single, "smartest" check. It's about layering several simple safeguards that cover each other's blind spots: MIME type from actual content, size limits, content scanning, controlled filename generation, and storage outside public access. No single layer is perfect on its own, but the combination makes the attack surface far harder to exploit.

For applications with higher security requirements, also consider adding external malware scanning and audit logging for every upload activity, so every incoming file can be traced back to its origin and history.

Top comments (0)