Phone:
 +947 782 0841
Email:
 mail[at]pasindudissan.xyz
Secondary Email:
 pasindudissan[at]proton.me

PGP key (Ed25519)
3300 B645 19CA C101 A0DD
D030 E40F 4B15 095C C7AF

© 2026 Pasindu Dissanayaka.

Posted by:

Pasindu Dissanayaka

Posted on:

Oct 15, 2026

Web Application Security Basics: Secure File Uploads

The Why?

First and foremost, unlike most of my previous articles this one is more focused on a Linux hosting environment running PHP but the theory and concept can be used across all platforms. The reason for it is because PHP is dead-simple and is the most popular language for many web applications and frameworks, and ergo also the language where most developers fumble when it comes to securely handling file uploads.

File uploads look harmless until someone sends a web shell, a polyglot image, a zip bomb, or a multi-gigabyte payload designed to exhaust your disk. File uploads are one of the most common features in web applications—and one of the easiest to implement badly. A file upload is not simply storage; it connects your application to the filesystem, web server, PHP runtime, database, and other users.

The goal is not to make uploads “safe” in an absolute sense. It is to make them boring: limited, validated, isolated, non-executable, access-controlled, and observable.

The threat model

Before writing code, as with all my articles we need to identify, define what we are defending against:

  • A user uploading a PHP web shell disguised as an image or document.
  • Path traversal through a user-controlled filename.
  • Oversized uploads consuming disk, memory, bandwidth, or processing time.
  • Malicious PDFs, Office documents, archives, or SVG files.
  • Unauthorized users downloading another user’s files.
  • Uploaded files being executed by PHP, Nginx, Apache, or a misconfigured container.
  • Silent failures where an attacker can upload files repeatedly without detection.

Treat every piece of upload metadata—the filename, extension, MIME type, and claimed size—as untrusted input. OWASP File Upload specifically recommends treating submitted filenames and content types as untrusted metadata and validating filenames after protocol decoding.

The rules I follow

These are the baseline rules I use for production PHP applications:

  1. Never trust the client. Browser-supplied filenames, MIME types, extensions, and sizes are suggestions, not facts.
  2. Use an allow-list. Deny by default and permit only file types the business genuinely needs.
  3. Validate the real content. Inspect magic bytes or use a reliable server-side type detector.
  4. Generate the stored filename yourself. Never use the original filename as the disk path.
  5. Store uploads outside the web root. Serve files through an authenticated application endpoint.
  6. Make storage locations non-executable. Disable PHP and script execution in every upload-related path.
  7. Scan risky files. Use antivirus, content disarm and reconstruction, or a sandbox when the file type justifies it.
  8. Log upload events. Record user ID, original filename, detected type, size, hash, result, and timestamp.

OWASP recommends storing uploads outside the web root where possible and generating a random server-side filename rather than relying on the user-supplied name. Mozilla’s security guidance similarly advises generating your own filenames, restricting allowed types, limiting file size, and storing files outside the website root when possible.

A practical PHP upload flow

The following snippet is a simplified production-oriented pattern. In a real application, move this into a service class, integrate it with your framework’s validation layer, and connect it to centralized logging and queueing.

<?php
declare(strict_types=1);
final class SecureUploadService
{ private const MAX_BYTES = 10 * 1024 * 1024; // 10 MB private const ALLOWED_TYPES = [ 'image/jpeg' => 'jpg', 'image/png' => 'png', 'application/pdf' => 'pdf', ]; public function __construct( private string $storageDir, private string $quarantineDir ) {} public function store(array $uploadedFile, int $userId): array { if (($uploadedFile['error'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_OK) { throw new RuntimeException('Upload failed.'); } $tmpPath = $uploadedFile['tmp_name']; if (!is_uploaded_file($tmpPath)) { throw new RuntimeException('Invalid upload source.'); } if (filesize($tmpPath) > self::MAX_BYTES) { throw new RuntimeException('File is too large.'); } $detectedType = $this->detectRealType($tmpPath); if (!isset(self::ALLOWED_TYPES[$detectedType])) { throw new RuntimeException('File type is not allowed.'); } $extension = self::ALLOWED_TYPES[$detectedType]; $storedName = bin2hex(random_bytes(16)) . '.' . $extension; $yearMonth = date('Y/m'); $destinationDir = rtrim($this->storageDir, '/') . '/' . $yearMonth; if (!is_dir($destinationDir) && !mkdir($destinationDir, 0750, true)) { throw new RuntimeException('Unable to create storage directory.'); } $destination = $destinationDir . '/' . $storedName; if (!move_uploaded_file($tmpPath, $destination)) { throw new RuntimeException('Unable to store file.'); } chmod($destination, 0640); return [ 'user_id' => $userId, 'original_name' => $this->sanitizeOriginalName($uploadedFile['name']), 'stored_name' => $storedName, 'stored_path' => $destination, 'mime_type' => $detectedType, 'size_bytes' => filesize($destination), 'sha256' => hash_file('sha256', $destination), 'uploaded_at' => date('c'), ]; } private function detectRealType(string $path): string { $finfo = new finfo(FILEINFO_MIME_TYPE); $type = $finfo->file($path); if ($type === false || $type === '') { return 'application/octet-stream'; } return $type; } private function sanitizeOriginalName(string $name): string { $name = basename($name); $name = preg_replace('/[^A-Za-z0-9._-]/', '_', $name); return mb_substr($name, 0, 180); }
}

Why each check matters

CheckPurpose
is_uploaded_file()Confirms the file came through PHP’s upload mechanism.
Size checkPrevents oversized uploads from consuming storage or bandwidth.
finfo MIME detectionInspects file content rather than trusting the browser’s declared type.
Allow-listRejects unexpected and high-risk formats by default.
Random stored filenameRemoves attacker control over the path and prevents filename collisions.
chmod()Restricts file permissions to the minimum practical level.
SHA-256 hashSupports integrity checks, duplicate detection, and incident investigation.

Reality check: finfo is useful, but it is not a complete security boundary. For high-risk formats—especially documents, archives, and files that will be processed by other systems—add a dedicated malware scanner, sandbox, or content-disarm process. And DO NOT blindly hand it over to your AI Agent to "process"

Store uploads outside the web root

A common mistake is storing uploads here:

/var/www/example.com/public/uploads/

That makes files directly reachable by URL and places them inside the web server’s execution context.

Prefer:

/var/www/example.com/storage/uploads/

Then expose files only through an authenticated controller, but bear in mind that authentication is not the only concern in a download handler. The endpoint should also ensure that the stored path cannot escape the intended storage root, and it should control how the browser interprets the response.

public function download(int $fileId)
{ $file = $this->fileRepository->findForUser( $fileId, auth()->id() ); if (!$file) { abort(404); } $storageRoot = realpath(config('filesystems.uploads_root')); $filePath = realpath($file->stored_path); if ( $storageRoot === false || $filePath === false || !str_starts_with($filePath, $storageRoot . DIRECTORY_SEPARATOR) ) { abort(404); } if (!is_readable($filePath)) { abort(404); } return response()->download( $filePath, $this->safeDownloadName($file->original_name), [ 'Content-Type' => $file->mime_type, 'X-Content-Type-Options' => 'nosniff', 'Content-Disposition' => sprintf( 'attachment; filename="%s"', $this->safeDownloadName($file->original_name) ), ] );
}
private function safeDownloadName(string $name): string
{ $name = basename($name); $name = preg_replace('/[^A-Za-z0-9._-]/', '_', $name); return mb_substr($name, 0, 180) ?: 'download';
}

This download handler is written with a “what if the attacker is already inside?” mindset. It does several things:

  • Re-checks authorization on every download. Knowing a URL is not authorization.
  • Does not blindly trust the stored path, even though it was generated server-side. A corrupted or compromised database record could point outside the permitted storage root, so both the configured root and candidate file path are resolved with realpath() and checked for containment.
  • Uses realpath() because it resolves traversal sequences and symbolic links before the containment check. A string-only prefix comparison would not reliably catch a symlink inside the storage directory pointing elsewhere.
  • Sanitizes the original filename before placing it in the Content-Disposition header. This protects the HTTP response and download name, while the realpath() check protects the filesystem.
  • Sends Content-Disposition: attachment, a server-decided Content-Type, and X-Content-Type-Options: nosniff. Together, these reduce the chance that uploaded binary file is rendered in the browser.

There are a handful of our precautionary measures you can take when implementing a download handler

  • Apply rate limits per user and IP address
  • Cap concurrent downloads where possible
  • Log and monitor unusual download volume, because a download endpoint can become a bandwidth and denial-of-service problem even when every file is legitimately accessible.
  • For file types your application does not explicitly support for inline viewing, use application/octet-stream rather than a browser-supplied or guessed MIME type.

Lock down Nginx

Even when uploads are outside the web root, explicitly deny access to the storage path:

location ^~ /storage/uploads/ { deny all; return 404;
}

If legacy constraints force uploads to remain under the web root, at minimum block executable file types:

location ^~ /uploads/ { location ~ \.(php|phtml|phar|cgi|pl|py|sh)$ { deny all; return 404; } add_header X-Content-Type-Options "nosniff" always; add_header Content-Disposition "attachment" always;
}

But “legacy reasons” is rarely a good enough reason. Move the files outside the web root.

It is a good idea to never allow directory specific web server configs such like .htaccess and to disable overrides at the server level itself. Although this may very much depend on your specific application requirements

Handle dangerous file formats

Some formats deserve more suspicion than others.

FormatMain riskPractical control
.php, .phtml, .pharRemote code executionNever allow; reject outright
.svgStored XSS through embedded scriptsSanitize, convert to PNG, or serve with restrictive headers
.pdfEmbedded JavaScript and parser exploitsScan, sandbox, or use content disarm/reconstruction
.docx, .xlsxMacros and parser vulnerabilitiesScan; consider converting to PDF
.zip, .tar.gzZip bombs, nested archives, malwareEnforce depth and size limits; scan safely
ImagesPolyglot files and EXIF abuseValidate dimensions and re-encode where appropriate

For an internal business application, the simplest strong policy is often: allow PDFs and common image types only, and reject or convert everything else.

Add rate limits and quotas

Authentication alone is not enough. A legitimate user can still fill your disk, consume bandwidth, or create operational noise.

Apply limits at multiple layers:

  • Nginx: client_max_body_size
  • PHP: upload_max_filesize and post_max_size
  • Application: per-file and per-user quotas
  • Network: rate limits by user and IP address
  • Infrastructure: disk-usage monitoring and alerts

Example Nginx configuration:

client_max_body_size 12m;

Example PHP configuration:

upload_max_filesize = 10M
post_max_size = 12M

Keep a small gap between the web server and PHP limits so the application receives a controlled rejection rather than an opaque connection error.

Monitoring you should have

Track upload activity as part of application security monitoring:

  • Upload success and failure counts.
  • Rejected MIME types.
  • Oversized upload attempts.
  • Repeated failures from the same user or IP.
  • Disk usage on the upload volume.
  • Files without an owner or past their retention period.
  • Antivirus or sandbox detections, if applicable.

A simple alert is enough: if upload failures spike or disk usage crosses 80%, someone should know immediately.

The minimum viable secure setup

If you implement only five controls, make them these:

  1. Store files outside the web root.
  2. Generate filenames server-side.
  3. Validate actual file content, not browser-supplied MIME type.
  4. Serve downloads through an authorization check.
  5. Disable script execution in every upload-related path.

Those five controls eliminate most of the catastrophic file-upload failures I see in real projects.

FAQ

Is checking the file extension enough?

No. Extensions are user-controlled metadata and can be changed trivially. Use an extension allow-list only as one layer, then validate the actual file content server-side. OWASP notes that an allowed extension alone does not establish that the content is safe.

Should I store uploaded files in the database?

I must admit there are times I do this myself, for certain use cases but not for file uploads, never for file uploads. Databases are convenient for metadata, but large binary files increase backup size, complicate performance, and can make restores slower.

Store files on object storage (such as S3/R2) or a dedicated storage service (such as Supabase/Firebase Storage) or as a last option on disk, and keep metadata in the database.

Generally for most projects I store files on disk, however for projects which require a larger upload space or have the need to store a large number of uploads I used a self-hosted S3 bucket, using MinIO and my own interface layer that provides me time-based urls. Alternatively for small use cases you could very easily utilize Cloudflare's R2.

Is mime_content_type() secure enough?

It is better than trusting the browser’s Content-Type, but it is not sufficient on its own. File-type detection can be bypassed with crafted files. It's better combined it with an allow-list, content inspection and additional scanning for high-risk formats.

Why generate filenames instead of sanitizing the original name?

Sanitizing reduces risk, but it still leaves the attacker influencing at least a part of the filename. Generating a random server-side filename removes that control entirely. You can retain the original filename separately for display purposes.

Where should uploads be stored?

Outside the web root, ideally on a dedicated volume or storage service. If files must be publicly served, use signed, expiring URLs and keep the underlying files inaccessible through direct paths.

Do I need antivirus scanning?

Not for every application, but yes for many real-world cases. If users can upload documents, archives, or files that will be opened by other users, site visitors, scanned by other systems, or shared internally, antivirus scanning and additionally sandboxing is a sensible control.

Further reading