Secure File Uploads in PHP
Handle file uploads through $_FILES safely: validate real file type by content (not client-supplied MIME type or extension alone), enforce size limits, generate random filenames, store uploads outside the public webroot, prevent execution of uploaded files, validate images specifically (dimensions, re-encoding), and enforce per-user storage quotas.
File upload handling is one of the highest-stakes surfaces in a web application, because it's the one feature deliberately designed to let a stranger put arbitrary bytes onto your server. Almost every real-world upload vulnerability traces back to trusting something the client told you — a filename, a Content-Type header, an extension — instead of verifying the file's actual content server-side. This lesson builds a complete, defense-in-depth upload pipeline, function by function, rather than a single 'move the file' snippet.
$_FILES structure and upload error codes
When a form is submitted with enctype="multipart/form-data" and includes a <input type="file" name="avatar">, PHP populates $_FILES['avatar'] with an associative array: 'name' (the client-supplied original filename — untrusted), 'type' (the client-supplied MIME type from the browser — also untrusted, easily spoofed), 'tmp_name' (the real path to a temporary file PHP already saved server-side, the only part you can actually trust the bytes of), 'error' (an UPLOAD_ERR_* constant), and 'size' (bytes, as reported during upload).
The 'error' field must be checked before touching the file at all: UPLOAD_ERR_OK (0) means the upload succeeded; anything else — UPLOAD_ERR_INI_SIZE/UPLOAD_ERR_FORM_SIZE (exceeded upload_max_filesize or a form-specified MAX_FILE_SIZE), UPLOAD_ERR_PARTIAL (only part of the file arrived), UPLOAD_ERR_NO_FILE (field was left empty) — means there is no valid tmp_name to process, and code that skips this check and calls move_uploaded_file() anyway will either emit warnings or, worse, operate on stale/incorrect data.
• $_FILES['field']['name'] — client-supplied filename, untrusted, never used as-is for storage • $_FILES['field']['type'] — client-supplied MIME type, untrusted, must be re-verified server-side • $_FILES['field']['tmp_name'] — real server-side temp path, the only trustworthy handle to the actual bytes • $_FILES['field']['error'] — an UPLOAD_ERR_* code, must be UPLOAD_ERR_OK before proceeding • $_FILES['field']['size'] — reported size in bytes, still worth re-checking against filesize(tmp_name)
Verifying real type by content, not by trusting the client
The client-supplied 'type' field and the file's extension are both just labels the uploader chose — a malicious .php file can be renamed photo.jpg and submitted with Content-Type: image/jpeg, and naive validation that only checks the extension or $_FILES['type'] will happily accept it. The fix is to inspect the file's actual bytes server-side using finfo (the Fileinfo extension), which examines the file's magic bytes/signature rather than trusting any label: finfo_file() (or the object-oriented new finfo(FILEINFO_MIME_TYPE)) returns the MIME type PHP itself detected from the content.
For images specifically, getimagesize($tmp_name) is an even stronger check for that category: it actually parses the image header structure, and it returns false (rather than plausible-looking dimensions) for a file that isn't a genuine, well-formed image of a supported type — so a renamed PHP shell script reliably fails this check even if it happened to also pass a loose finfo check. Combine an allow-list of accepted real MIME types with a matching allow-list of extensions, and reject anything that doesn't clear both.
Size limits, random filenames, and quotas
Size limits exist at two layers and both matter: php.ini's upload_max_filesize and post_max_size are the server's hard ceiling (an upload exceeding these never even fully reaches your script, arriving as UPLOAD_ERR_INI_SIZE), while application-level checks against filesize($tmp_name) enforce a tighter, feature-specific limit (a 2MB avatar limit even though the server would technically accept 20MB). Never rely on a JavaScript-side size check alone — it's trivially bypassed by anyone who disables JS or crafts a raw request.
The original client-supplied filename should never be used to name the stored file: it can contain path traversal sequences (../../etc/passwd), null bytes, or characters that behave unexpectedly on the destination filesystem, and two different users uploading 'photo.jpg' would otherwise collide. Generate a random, collision-resistant name server-side instead — bin2hex(random_bytes(16)) or a UUID — and keep the original filename, if needed for display, only as a separate database column, never as the actual on-disk path component.
A quota (limiting how much total storage, or how many files, one account may accumulate) stops a single user from filling the disk through many individually-small-enough uploads. It's enforced by tracking cumulative bytes used per user (a running total column, updated transactionally alongside each successful upload) and rejecting new uploads once the account's total would exceed its allotment — checked before the file is persisted, not as an afterthought cleanup job.
Storing outside the public root and preventing execution
Uploaded files should be stored in a directory that is not reachable directly by URL — outside the document root entirely (e.g. /var/app/uploads/ when the webroot is /var/app/public/) — rather than under public_html/uploads/. If an attacker's malicious file ever does slip through validation, storing it outside the webroot means there's no URL that serves it directly for execution; the application instead reads the file server-side (with PHP's own file functions) and streams its contents back through a controlled download script that sets its own Content-Type and Content-Disposition headers, never letting the web server interpret it as a request path.
As a second, independent layer, prevent the web server from ever executing anything inside an uploads directory even if one somehow does end up under the webroot: an .htaccess with php_flag engine off (Apache) or an Nginx location block that refuses to pass requests under /uploads/ to the PHP-FPM handler. Belt-and-suspenders matters here — content validation can have edge cases, and denying execution at the web-server level is a second independent control that doesn't depend on the validation logic being perfect.
• Store uploads outside the public webroot; serve them only through a script that streams bytes with controlled headers • Deny script execution in any upload-adjacent directory at the web-server config level, as a second independent layer • Never trust the client-supplied filename or extension for the on-disk path — generate a random name server-side • Re-verify size against filesize(), not just the client-reported $_FILES['size']
Image-specific validation: dimensions and re-encoding
For image uploads specifically, getimagesize() gives width, height, and a detected type constant (IMAGETYPE_JPEG, IMAGETYPE_PNG, etc.) — reject anything outside sane bounds (absurdly large dimensions can be a denial-of-service vector when later processing, e.g. resizing, tries to allocate memory proportional to pixel count) and anything whose detected type isn't in your allow-list, regardless of what extension was submitted.
The strongest image-specific defense is re-encoding: load the uploaded image with GD (imagecreatefromjpeg()/imagecreatefrompng(), or the newer Imagick extension) and write it back out as a fresh file using the corresponding imagejpeg()/imagepng() output function. This process discards anything that isn't valid, well-formed image data structurally embedded in a valid image container — polyglot files (bytes crafted to be simultaneously valid as an image and as some other payload) generally do not survive being decoded and re-encoded by a real image library, because the re-encode only ever writes out pixel data the library actually understood as pixels.
Upload validation building blocks
move_uploaded_file() (not rename() or copy()) is required for files from $_FILES — it verifies the source path really was created by PHP's own upload machinery, guarding against a class of path-injection tricks. Always allow-list both the detected MIME type AND the detected image type constant; matching either one alone is not sufficient.
<?php
// Server-side type detection -- never trust $_FILES['type']
$finfo = new finfo(FILEINFO_MIME_TYPE);
$realMime = $finfo->file($_FILES['avatar']['tmp_name']);
// Strong image-specific check: parses actual image structure
$imageInfo = getimagesize($_FILES['avatar']['tmp_name']);
// $imageInfo === false means "not a real, well-formed image"
// Random, collision-resistant filename -- never the client's filename
$safeName = bin2hex(random_bytes(16)) . '.jpg';
// Store outside the public webroot
$destination = '/var/app/uploads/' . $safeName;
move_uploaded_file($_FILES['avatar']['tmp_name'], $destination);Code Examples
This is the minimum baseline before doing anything else with an upload: check the error code first (a failed or absent upload has no meaningful tmp_name to process), then enforce an application-level size ceiling by checking both the client-reported size and the server-measured filesize() of the temp file, since the two should agree and disagreeing is itself suspicious.
Common mistake: Stopping here is not enough — nothing here has verified the file's actual type yet, only its size.
Two independent checks gate acceptance: finfo detects the real MIME type from content, and getimagesize() independently confirms the bytes form a structurally valid image — a renamed non-image file fails both. The stored filename is fully server-generated, and $uploadDir is expected to be a path outside the public webroot, passed in by the caller.
if (!isset($allowed[$mime])) { throw new RuntimeException(...); }
This pipeline enforces a per-file limit, then a cumulative per-user quota checked against a running database total before doing any real work, then validates the image structurally, and finally re-encodes it with GD rather than simply moving the uploaded bytes as-is — imagecreatefromjpeg()/imagejpeg() only ever round-trip genuine pixel data, so anything appended or hidden alongside a valid JPEG header does not survive. In production this quota check and the final INSERT should run inside a single database transaction to avoid a race where two simultaneous uploads both pass the quota check before either is recorded.
<?php
if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_FILES['document'])) {
$file = $_FILES['document'];
if ($file['error'] !== UPLOAD_ERR_OK) {
die('Upload failed with error code ' . $file['error']);
}
$maxBytes = 5 * 1024 * 1024; // 5 MB
if ($file['size'] > $maxBytes || filesize($file['tmp_name']) > $maxBytes) {
die('File is too large.');
}
echo 'Upload received, ' . $file['size'] . ' bytes.';
}<?php
function handleAvatarUpload(array $file, string $uploadDir): string
{
if ($file['error'] !== UPLOAD_ERR_OK) {
throw new RuntimeException('Upload error: ' . $file['error']);
}
$maxBytes = 2 * 1024 * 1024;
if (filesize($file['tmp_name']) > $maxBytes) {
throw new RuntimeException('File exceeds the 2MB limit.');
}
// Verify REAL content type, never the client-supplied one
$finfo = new finfo(FILEINFO_MIME_TYPE);
$mime = $finfo->file($file['tmp_name']);
$allowed = ['image/jpeg' => 'jpg', 'image/png' => 'png', 'image/webp' => 'webp'];
if (!isset($allowed[$mime])) {
throw new RuntimeException("Rejected: detected type '$mime' is not an allowed image type.");
}
// getimagesize() as a second, structural confirmation
if (getimagesize($file['tmp_name']) === false) {
throw new RuntimeException('File is not a valid, well-formed image.');
}
$randomName = bin2hex(random_bytes(16)) . '.' . $allowed[$mime];
$destination = rtrim($uploadDir, '/') . '/' . $randomName;
if (!move_uploaded_file($file['tmp_name'], $destination)) {
throw new RuntimeException('Failed to store uploaded file
…<?php
function handleImageUploadWithQuota(
array $file,
string $uploadDir,
int $userId,
PDO $db,
int $quotaBytes = 50 * 1024 * 1024
): string {
if ($file['error'] !== UPLOAD_ERR_OK) {
throw new RuntimeException('Upload error: ' . $file['error']);
}
$size = filesize($file['tmp_name']);
if ($size > 5 * 1024 * 1024) {
throw new RuntimeException('File exceeds the 5MB per-file limit.');
}
// Enforce a per-user storage quota BEFORE persisting the new file
$stmt = $db->prepare('SELECT COALESCE(SUM(size_bytes), 0) FROM uploads WHERE user_id = ?');
$stmt->execute([$userId]);
$usedBytes = (int) $stmt->fetchColumn();
if ($usedBytes + $size > $quotaBytes) {
throw new RuntimeException('Storage quota exceeded.');
}
$imageInfo = getimagesize($file['tmp_name']);
if ($imageInfo === false || !in_array($imageInfo[2], [IMAGETYPE_JPEG, IMAGETYPE_PNG], true)) {
throw new RuntimeException('Only JPEG and PNG images are allowed.');
}
// Re-encode: strips anything that survived as "valid image bytes" but
// wasn't genuine pixel data understood by GD -- a strong anti-polyglot step.
$sou
…Secure upload pipeline
Every stage can reject; nothing about the client-supplied name, extension, or Content-Type is trusted at any point, and the file only reaches disk after all checks pass.
$_FILES['avatar']
|
v check ['error'] === UPLOAD_ERR_OK ---- fail --> reject
|
v filesize(tmp_name) <= limit ---------- fail --> reject
|
v finfo detects real MIME type --------- not allow-listed --> reject
|
v getimagesize() structurally valid ---- false --> reject
|
v (images) re-encode via GD/Imagick ---- decode fails --> reject
|
v generate random filename
|
v check cumulative user quota ---------- over quota --> reject
|
v move_uploaded_file() to a directory OUTSIDE the public webroot
|
v record filename + size in the databaseImportant Rules
Never trust $_FILES['type'] or the client-supplied filename/extension — verify real content type server-side with finfo and, for images, getimagesize().
Enforce size limits at both php.ini (upload_max_filesize/post_max_size) and application level (filesize($tmp_name) against a feature-specific cap).
Generate a random server-side filename for storage; never write the client-supplied filename to disk or to a path.
Store uploaded files outside the public webroot, and deny script execution in any upload-adjacent directory as a second, independent control.
For images, re-encode with GD/Imagick after validation — this is the strongest defense against polyglot files that pass structural checks but embed non-image payloads.
Where this shows up
Content management systems and any app with avatars, attachments, or document uploads (WordPress media uploads, help-desk ticket attachments, resume-upload forms) all implement some version of this pipeline; a large fraction of historic PHP remote-code-execution vulnerabilities trace back to an upload feature that trusted a client-supplied extension or MIME type and stored the result inside a web-servable directory that also executed PHP.
if ($_FILES['avatar']['type'] === 'image/jpeg') { move_uploaded_file($_FILES['avatar']['tmp_name'], 'public/uploads/' . $_FILES['avatar']['name']); }
$mime = (new finfo(FILEINFO_MIME_TYPE))->file($_FILES['avatar']['tmp_name']); if ($mime === 'image/jpeg' && getimagesize($_FILES['avatar']['tmp_name']) !== false) { $name = bin2hex(random_bytes(16)) . '.jpg'; move_uploaded_file($_FILES['avatar']['tmp_name'], '/var/app/uploads/' . $name); }
The wrong version trusts the client-supplied 'type' field (trivially spoofable), uses the client's own filename as the stored path (collision- and traversal-prone), and stores directly under the public webroot (public/uploads/), meaning any file that slips through — including a renamed PHP script — is both reachable by URL and potentially executable by the web server.
[multiple-choice] Why is $_FILES['field']['type'] insufficient on its own for validating an uploaded file's type? A. It is always empty for images specifically B. It is a value supplied by the client's browser and can be freely spoofed, so it must be re-verified server-side by inspecting actual file content C. PHP removes this field automatically for security D. It only exists for files larger than 2MB
B. It is a value supplied by the client's browser and can be freely spoofed, so it must be re-verified server-side by inspecting actual file content — The 'type' field is simply whatever Content-Type the browser (or a crafted raw request) declared for that part of the multipart body — it's client-controlled and easily set to anything, so real validation must use something like finfo to detect the type from the file's actual bytes.
[multiple-choice] Why should uploaded files be stored outside the public webroot rather than in a directory served directly by the web server? A. It makes uploads process faster B. PHP requires it syntactically for move_uploaded_file() to work C. If a malicious file ever slips past validation, there is no URL that lets the web server serve or execute it directly, since the application must read and stream it through controlled code instead D. It's only a convention with no security benefit
C. If a malicious file ever slips past validation, there is no URL that lets the web server serve or execute it directly, since the application must read and stream it through controlled code instead — Storing outside the webroot means an uploaded file has no direct URL at all — even in the worst case where validation is imperfect and something malicious gets through, there's no request path that would cause the web server to execute or directly serve it; the app must deliberately read and stream it back with controlled headers.
Write a function acceptUpload(array $file, string $storageDir) that: rejects anything but UPLOAD_ERR_OK, rejects files over 3MB, verifies the real MIME type is image/png or image/jpeg using finfo, confirms getimagesize() succeeds, generates a random filename with the correct extension, and moves the file into $storageDir (assume it is outside the webroot). Return the generated filename.
function acceptUpload(array $file, string $storageDir): string { if ($file['error'] !== UPLOAD_ERR_OK) throw new RuntimeException('Upload failed.'); if (filesize($file['tmp_name']) > 3 * 1024 * 1024) throw new RuntimeException('Too large.'); $mime = (new finfo(FILEINFO_MIME_TYPE))->file($file['tmp_name']); $allowed = ['image/png' => 'png', 'image/jpeg' => 'jpg']; if (!isset($allowed[$mime])) throw new RuntimeException('Bad type.'); if (getimagesize($file['tmp_name']) === false) throw new RuntimeException('Not a valid image.'); $name = bin2hex(random_bytes(16)) . '.' . $allowed[$mime]; if (!move_uploaded_file($file['tmp_name'], rtrim($storageDir, '/') . '/' . $name)) { throw new RuntimeException('Move failed.'); } return $name; }
Build an allow-list array mapping MIME type strings to their file extension, and use that same map both to validate and to pick the extension for the random filename.