Security 12 min read

Laravel File Upload Security: Preventing Remote Code Execution Through Unrestricted Uploads

File uploads are one of the most dangerous features in any Laravel application. A single misconfiguration can let an attacker upload a PHP shell and take full control of your server. Here is how to lock it down properly.

Matt King
Matt King
April 14, 2026
Last updated: April 14, 2026
Laravel File Upload Security: Preventing Remote Code Execution Through Unrestricted Uploads

File upload functionality is one of the most common features in web applications. Profile photos, document attachments, CSV imports, media galleries. Nearly every Laravel app handles file uploads in some form.

It is also one of the most dangerous features you can build. A single misconfiguration in your upload handling can give an attacker full remote code execution (RCE) on your server. Not read access. Not limited access. Full control.

This guide walks through exactly how these attacks work, the common mistakes that enable them, and how to build file uploads in Laravel that are genuinely secure.


How File Upload RCE Actually Works

The attack is straightforward. Here is what happens step by step:

  1. Your application has a file upload form (profile photo, document upload, etc.)
  2. The attacker uploads a file named shell.php containing malicious PHP code
  3. Your application saves it to a publicly accessible directory like public/uploads/
  4. The attacker visits https://yourapp.com/uploads/shell.php in their browser
  5. Your web server (Nginx or Apache) sees the .php extension and executes the file
  6. The attacker now has full RCE on your server

The malicious file can be as simple as this:

<?php
// This one-liner gives an attacker a full web shell
echo shell_exec($_GET['cmd']);

With this shell in place, the attacker can run any command on your server:

https://yourapp.com/uploads/shell.php?cmd=cat%20/var/www/.env
https://yourapp.com/uploads/shell.php?cmd=whoami
https://yourapp.com/uploads/shell.php?cmd=mysqldump%20-u%20root%20database

Your .env file, database credentials, API keys, user data. Everything is exposed. This is not a theoretical attack. It is one of the most commonly exploited vulnerabilities in PHP applications.


The Five Common Mistakes

1. Trusting the MIME Type

This is the most frequent mistake. Developers validate the MIME type and assume they are safe:

// DANGEROUS: MIME type can be spoofed
$request->validate([
    'avatar' => 'required|file|mimetypes:image/jpeg,image/png',
]);

The problem is that MIME types are sent by the client. An attacker can upload a PHP file with a Content-Type: image/jpeg header. Your validation passes, and a PHP shell lands on your server.

MIME type validation is useful as an additional layer, but it must never be your only defence.

2. Storing Files in Public Directories

// DANGEROUS: Files are directly accessible via the web server
$request->file('document')->store('uploads', 'public');

When you use the public disk, files end up in storage/app/public/ and are symlinked to public/storage/. Any file stored here is directly accessible via URL, and if the web server is configured to execute PHP files (which it is, by default), uploaded PHP files will execute.

3. Preserving the Original Filename

// DANGEROUS: Attacker controls the filename and extension
$file = $request->file('avatar');
$file->storeAs('uploads', $file->getClientOriginalName(), 'public');

Using the client-provided filename means the attacker controls the file extension. They upload shell.php, and that is exactly what gets saved. Even if you add some prefix, the .php extension survives.

4. Not Validating File Extensions

Some developers validate MIME types but skip extension validation entirely. The web server does not care about MIME types. It uses the file extension to decide how to handle a request. A file named image.php with a MIME type of image/jpeg will still be executed as PHP.

5. Missing File Size Limits

While not directly an RCE vector, missing file size limits enable denial-of-service attacks. An attacker can upload multi-gigabyte files to fill your disk, crash your application, or rack up storage costs on cloud providers.


Secure Upload Implementation in Laravel

Here is a complete, secure implementation that addresses every vulnerability above:

<?php

namespace App\Http\Controllers;

use Illuminate\Http\Request;
use Illuminate\Support\Str;
use Illuminate\Validation\Rules\File;

class SecureUploadController extends Controller
{
    /**
     * Allowed extensions mapped to their valid MIME types.
     */
    private const ALLOWED_TYPES = [
        'jpg'  => ['image/jpeg'],
        'jpeg' => ['image/jpeg'],
        'png'  => ['image/png'],
        'gif'  => ['image/gif'],
        'webp' => ['image/webp'],
        'pdf'  => ['application/pdf'],
    ];

    public function store(Request $request)
    {
        $request->validate([
            'file' => [
                'required',
                File::types(array_keys(self::ALLOWED_TYPES))
                    ->max(10 * 1024), // 10MB limit
            ],
        ]);

        $file = $request->file('file');

        // Double-check: validate extension against allowed list
        $extension = strtolower($file->getClientOriginalExtension());

        if (! array_key_exists($extension, self::ALLOWED_TYPES)) {
            abort(422, 'File type not allowed.');
        }

        // Double-check: validate MIME type matches the extension
        $mimeType = $file->getMimeType();

        if (! in_array($mimeType, self::ALLOWED_TYPES[$extension], true)) {
            abort(422, 'File content does not match its extension.');
        }

        // Generate a random filename to prevent directory traversal
        // and eliminate the original extension
        $filename = Str::uuid() . '.' . $extension;

        // Store in a PRIVATE disk, not public
        $path = $file->storeAs(
            'uploads/' . now()->format('Y/m'),
            $filename,
            's3' // Use cloud storage to isolate from the web server
        );

        return response()->json([
            'path' => $path,
            'url'  => $this->generateSecureUrl($path),
        ]);
    }

    /**
     * Generate a temporary signed URL for accessing the file.
     */
    private function generateSecureUrl(string $path): string
    {
        return \Storage::disk('s3')->temporaryUrl(
            $path,
            now()->addMinutes(30)
        );
    }
}

Let us break down what this does right:

  1. Extension validation: Uses Laravel's File::types() rule to whitelist specific extensions
  2. MIME type cross-check: Verifies the detected MIME type matches what the extension claims
  3. Random filenames: Replaces the client filename with a UUID, eliminating any chance of path traversal or extension manipulation
  4. Private storage: Files go to S3, not to a public directory on the web server
  5. Signed URLs: Files are accessed through time-limited signed URLs, not direct public links
  6. Size limits: Hard cap at 10MB prevents storage abuse

Serving Private Files Securely

If you cannot use S3, you can serve files from private storage through a controller:

<?php

namespace App\Http\Controllers;

use Illuminate\Support\Facades\Storage;
use Symfony\Component\HttpFoundation\StreamedResponse;

class FileDownloadController extends Controller
{
    public function show(string $path): StreamedResponse
    {
        // Verify the authenticated user has access to this file
        $this->authorize('download', $path);

        if (! Storage::disk('local')->exists($path)) {
            abort(404);
        }

        $mimeType = Storage::disk('local')->mimeType($path);

        // Only serve known-safe content types
        $safeMimeTypes = [
            'image/jpeg', 'image/png', 'image/gif',
            'image/webp', 'application/pdf',
        ];

        if (! in_array($mimeType, $safeMimeTypes, true)) {
            abort(403, 'File type cannot be served.');
        }

        return Storage::disk('local')->download(
            $path,
            basename($path),
            ['Content-Type' => $mimeType]
        );
    }
}

This approach stores files in storage/app/ (not publicly accessible) and streams them through your application with proper authorization checks.


Content-Type Validation vs Extension Validation

This distinction causes a lot of confusion, so let us be precise about what each one does and why both matter.

Extension validation checks the filename suffix (.jpg, .php, .exe). This is what your web server uses to decide whether to execute a file or serve it statically. If a file ends in .php, Nginx and Apache will execute it. Extension validation is your primary defence against RCE.

Content-type (MIME) validation checks the actual file contents. Laravel's getMimeType() method uses the fileinfo PHP extension to read magic bytes from the file header and determine what the file actually is. A PHP file masquerading as image.jpg will be detected as text/x-php by this check.

You need both:

$request->validate([
    'file' => [
        'required',
        // Extension validation: only allow these file extensions
        File::types(['jpg', 'jpeg', 'png', 'gif', 'pdf'])
            ->max(10 * 1024),
    ],
]);

// Content-type validation: verify file contents match the claimed type
$file = $request->file('file');
$detectedMime = $file->getMimeType(); // Uses fileinfo, not client headers

if (! in_array($detectedMime, ['image/jpeg', 'image/png', 'image/gif', 'application/pdf'])) {
    abort(422, 'File contents do not match an allowed type.');
}

Extension validation alone can be bypassed with double extensions (shell.php.jpg on misconfigured servers). MIME validation alone can be bypassed by spoofing headers. Together, they cover each other's weaknesses.


Using S3 to Eliminate Server-Side Execution

Moving uploads to Amazon S3 (or any object storage service like DigitalOcean Spaces, Cloudflare R2, or MinIO) is the single most effective mitigation against file upload RCE.

Why? Because S3 does not execute code. Ever. If an attacker uploads shell.php to S3 and requests it, S3 returns the raw PHP source code as a download. It never runs it.

Configure your Laravel filesystems.php:

// config/filesystems.php
'disks' => [
    'uploads' => [
        'driver' => 's3',
        'key'    => env('AWS_ACCESS_KEY_ID'),
        'secret' => env('AWS_SECRET_ACCESS_KEY'),
        'region' => env('AWS_DEFAULT_REGION'),
        'bucket' => env('AWS_UPLOAD_BUCKET'),
        'visibility' => 'private', // Files are not publicly accessible
    ],
],

Important details for S3 security:

  • Set bucket visibility to private by default. Use signed URLs for access.
  • Disable public access at the bucket level via the S3 Block Public Access settings
  • Create a dedicated bucket for uploads. Do not mix uploads with your application assets.
  • Set up lifecycle rules to automatically delete old uploads if they are temporary
  • Use IAM roles with minimal permissions. Your application only needs s3:PutObject, s3:GetObject, and s3:DeleteObject

Nginx Configuration Hardening

If you must store uploads on the local filesystem, configure Nginx to never execute PHP in your upload directory:

# Block PHP execution in upload directories
location ~* /storage/.*\.php$ {
    deny all;
    return 403;
}

# Serve upload files with restrictive headers
location /storage/ {
    add_header X-Content-Type-Options nosniff;
    add_header Content-Security-Policy "default-src 'none'";
    add_header X-Frame-Options DENY;
}

For Apache, use an .htaccess file in your upload directory:

# Disable PHP execution entirely
php_flag engine off

# Deny access to PHP files as a fallback
<FilesMatch "\.php$">
    Require all denied
</FilesMatch>

These configurations act as a safety net. Even if a PHP file gets past your validation, the web server refuses to execute it.


Additional Hardening Measures

Scan uploaded files for malware

For applications that accept document uploads from untrusted users, consider scanning files with ClamAV:

use Illuminate\Support\Facades\Process;

$result = Process::run(
    'clamdscan --no-summary ' . escapeshellarg($file->getRealPath())
);

if ($result->exitCode() !== 0) {
    abort(422, 'File failed security scan.');
}

Strip metadata from images

Uploaded images can contain EXIF data with GPS coordinates, device information, and even embedded scripts. Use a library like Intervention Image to strip metadata:

use Intervention\Image\Laravel\Facades\Image;

$image = Image::read($file->getRealPath());
$image->save($destinationPath); // Re-encoding strips EXIF data

Limit upload frequency

Rate-limit your upload endpoints to prevent abuse:

// In your route definition
Route::post('/upload', [SecureUploadController::class, 'store'])
    ->middleware(['auth', 'throttle:uploads']);

// In RouteServiceProvider or bootstrap/app.php
RateLimiter::for('uploads', function (Request $request) {
    return Limit::perMinute(10)->by($request->user()->id);
});

The Security Checklist for File Uploads

Before shipping any file upload feature, verify every item on this list:

Check Status
File extensions validated against a whitelist Required
MIME types validated server-side (not from client headers) Required
Original filenames replaced with random names Required
Files stored outside the web root or on cloud storage Required
PHP execution disabled in upload directories Required
File size limits enforced Required
Upload endpoints require authentication Required
Upload endpoints are rate-limited Recommended
Files scanned for malware Recommended
Image metadata stripped Recommended
Signed URLs used for file access Recommended

How StackShield Catches Upload Misconfigurations

Building secure uploads is one side of the equation. Verifying that your production environment is actually configured correctly is the other.

StackShield runs 30+ external security checks against your live Laravel application, scanning it the way an attacker would. It checks for publicly accessible storage directories, exposed upload paths, server misconfigurations that allow PHP execution in storage folders, and missing security headers that could enable uploaded content to be exploited.

The difference between knowing the best practices and actually following them in production is where vulnerabilities live. Configuration drift, deployment mistakes, a junior developer's PR that changes the storage disk. These things happen.

Run a free scan on your Laravel app to see if your upload configuration, along with 30+ other security checks, passes inspection. It takes under 60 seconds.


Further Reading

Frequently Asked Questions

Can an attacker really execute PHP code through a file upload?

Yes. If your application stores uploaded files in a publicly accessible directory and does not validate file extensions properly, an attacker can upload a .php file containing arbitrary code. When they request that file via its URL, the web server executes it as PHP. This gives the attacker full remote code execution on your server, allowing them to read your .env file, access your database, pivot to other systems, and more.

Is MIME type validation enough to prevent malicious uploads?

No. MIME type validation alone is not sufficient. MIME types are sent by the client and can be spoofed trivially. An attacker can set the Content-Type header to image/jpeg while uploading a .php file. You must validate both the file extension and the MIME type on the server side. Extension validation is the more critical of the two because the web server uses the file extension, not the MIME type, to decide whether to execute a file.

Should I store uploads in the public directory in Laravel?

No. Storing uploads in the public directory means the web server can serve them directly, and if any uploaded file has a .php extension (or another executable extension), it will be executed. Store uploads outside the web root or, better yet, use cloud storage like S3. If files must be served to users, use a controller that streams the file from private storage with proper headers.

Does moving to S3 completely eliminate file upload security risks?

S3 eliminates the remote code execution risk because S3 serves files as static objects and never executes PHP or any server-side code. However, other risks remain. An attacker could still upload malicious HTML files that execute JavaScript (stored XSS), excessively large files that consume storage costs, or files with misleading names that trick other users into downloading malware. You still need proper validation even with S3.

What file upload checks does StackShield perform?

StackShield scans your Laravel application externally, checking for exposed storage directories, publicly accessible upload endpoints without authentication, misconfigured storage links, and server configurations that could allow PHP execution in upload directories. It runs 30+ Laravel-specific security checks on every scan, catching misconfigurations before attackers find them.

Stay Updated on Laravel Security

Get actionable security tips, vulnerability alerts, and best practices for Laravel apps.