Skip to content
intermediate Phase 7 · Filesystem and Session Management

Handling File Uploads

Process multipart forms and move uploaded files securely.

50m
0 problems
Topic Progress 0%

Where Did $_FILES Come From? Multipart Requests and the Error Codes

A profile form lets a user pick an avatar. The code reads $_FILES['avatar']['error'], and on the first real submission it dies with Warning: Undefined array key "avatar". Nothing is wrong with the PHP — the <form> tag is missing enctype="multipart/form-data". Without that attribute the browser sends the file field as an ordinary text field (usually an empty string), $_FILES stays empty, and the upload never happens. That attribute is the door; everything below assumes it is open.

With it, the browser sends one request body containing named parts, and PHP does the heavy lifting: it writes the raw bytes of each file to a temporary location (upload_tmp_dir, falling back to the system temp directory) and records what it knows in $_FILES. The shape surprises people who expect one flat array. Under each field name sit five parallel arrays:

// <input type="file" name="avatar">
$_FILES['avatar']['name'];      // client-supplied filename: "../../etc/passwd" is legal input
$_FILES['avatar']['type'];      // MIME the browser claims — attacker-controlled, not evidence
$_FILES['avatar']['tmp_name'];  // server path, e.g. /tmp/phpXYz123 — valid only this request
$_FILES['avatar']['error'];     // UPLOAD_ERR_* code, integer
$_FILES['avatar']['size'];      // bytes as reported by PHP

// <input type="file" name="photos[]" multiple>
$_FILES['photos']['name'][0];   // now arrays of arrays: index by position

$_FILES['avatar']['error'] is the field to read first, and it has a fixed vocabulary worth memorizing — interviewers ask for these:

Code Constant Meaning Your response
0 UPLOAD_ERR_OK arrived intact proceed to validation
1 UPLOAD_ERR_INI_SIZE larger than upload_max_filesize "file too large, max N MB"
2 UPLOAD_ERR_FORM_SIZE larger than the form's MAX_FILE_SIZE same message
3 UPLOAD_ERR_PARTIAL transfer aborted mid-file "retry the upload"
4 UPLOAD_ERR_NO_FILE user submitted no file treat as empty input, not an error
6 UPLOAD_ERR_NO_TMP_DIR temp directory missing server misconfiguration; alert ops
7 UPLOAD_ERR_CANT_WRITE disk write failed server fault; alert ops
8 UPLOAD_ERR_EXTENSION a PHP extension blocked the upload policy rejection

Note the trap in codes 1 and 2: they are INI-level rejections, so the file never reaches your code. A user who ignores your in-page "max 2 MB" hint gets an ugly server error unless you translate the code into your own message.

The limits themselves form a hierarchy that confuses even experienced developers:

per file:   upload_max_filesize   (default 2M)
per request: post_max_size        (default 8M) — must be >= upload_max_filesize
handling:   memory_limit          — only matters if you load the file into memory

When the whole request body exceeds post_max_size, PHP discards both $_POST and $_FILES — the error code is not even set, because the field arrays were never populated. The giveaway is a $_POST that is empty while CONTENT_LENGTH is huge. The web server often gets there first: nginx answers 413 Request Entity Too Large from client_max_body_size before PHP runs at all. Knowing which layer rejected the request is the difference between a fixable message and a ghost bug.

Finally, $_FILES['...']['tmp_name'] lives only for the duration of the request. PHP deletes it at shutdown, so any code path that validates but forgets to store the file silently discards the user's data — the next chapter covers what to do with it.

The Validation Checklist Before move_uploaded_file()

The temporary file is proven legitimate by PHP; everything else about it — name, type, content — is a claim by the client. The job between $_FILES and durable storage is turning claims into verified facts:

$f = $_FILES['avatar'] ?? null;
if ($f === null || $f['error'] !== UPLOAD_ERR_OK) {
    $msg = match ($f['error'] ?? UPLOAD_ERR_NO_FILE) {
        UPLOAD_ERR_INI_SIZE, UPLOAD_ERR_FORM_SIZE => 'File exceeds the 2 MB limit.',
        UPLOAD_ERR_NO_FILE                        => 'Please choose a file.',
        default                                    => 'Upload failed, try again.',
    };
    throw new InvalidArgumentException($msg);
}

if ($f['size'] > 2 * 1024 * 1024) {                 // app limit, independent of INI
    throw new InvalidArgumentException('File exceeds the 2 MB limit.');
}

$allowed = [
    'image/jpeg' => 'jpg',
    'image/png'  => 'png',
    'image/webp' => 'webp',
];
$mime = (new finfo(FILEINFO_MIME_TYPE))->file($f['tmp_name']);
if (!isset($allowed[$mime])) {
    throw new InvalidArgumentException('Only JPEG, PNG, or WebP images are accepted.');
}

$name = bin2hex(random_bytes(16)) . '.' . $allowed[$mime];   // extension from the sniff, not the client
$dest = '/var/www/storage/avatars/' . $name;

if (!move_uploaded_file($f['tmp_name'], $dest)) {
    throw new RuntimeException('Could not store the upload.');
}
chmod($dest, 0644);

Every line answers a specific attack:

  • $f['name'] is never used. It can contain ../../, NUL-era tricks, or shell.php. The stored name is random bytes plus an extension you derived yourself — which also means no collisions, no overwriting another user's file, and no guessing other people's URLs.
  • $f['type'] is never checked for security. It is a header the client chose. finfo reads the file's leading bytes on the server; a .exe renamed cat.png fails the sniff. For images, getimagesize() adds a structural check that the header really parses as an image.
  • Whitelist, never blacklist. in_array($ext, ['jpg','png']) beats !in_array($ext, ['php','phtml']) every time — the blacklist misses the next dangerous extension and your server's handler list may execute files you have never heard of.

move_uploaded_file() deserves its own explanation, because it is not copy() with a different name. It performs two operations: it verifies through the SAPI that the source really is the file PHP received for this POST (the same check is_uploaded_file() does), then it moves it — copying across filesystems when temp and destination are on different mounts, where a plain rename() would fail. A local path you pass to copy() skips the provenance check entirely; if any code path can be steered to copy an arbitrary server file into your storage, you have built a file-disclosure bug. Use the upload API for uploads.

For multi-file fields, loop over the parallel arrays by index, and remember MAX_FILE_SIZE — a hidden field some frameworks emit — is a client-side hint only: a browser may ignore it entirely, so $f['size'] is still checked server-side. The same applies to your app-level limit: INI directives protect the server, your explicit check is what enforces the promise the UI made to the user. Log the code and size of every rejected upload; a spike in code-1 rejections after a marketing campaign means your advertised limit is smaller than what people are trying to send. Also note that max_file_uploads (default 20) caps how many file parts one request may carry — beyond it, extra files are silently absent rather than errored.

Validation failures need no cleanup: PHP removes the temporary file at request end. On success the temporary file is gone by definition — it was moved. What you must never do is leave the destination name under the client's control, which is exactly what the next topic attacks.

Assume It Runs: Storing Uploads So a Shell Never Executes

A startup stored uploads at public/uploads/ with the client's filename intact. An attacker sent avatar.php.jpg against a server whose Apache config mapped handlers by extension pattern, then requested /uploads/avatar.php.jpg — and PHP executed the image. The validation was fine; the assumption that stored files are inert was wrong. Treat every upload as code that someone will eventually try to run.

The strongest control is architectural: store files outside the document root (/var/www/storage/avatars, reachable only through a script that validates the id and streams it with readfile() plus Content-Type you chose). Nothing under the web root can execute what the web server cannot see.

When the files must be web-accessible — a media CDN path, a legacy layout — deny execution at the server:

# nginx: never run anything from the upload tree
location ^~ /uploads/ {
    location ~ \.php$ { deny all; }
    add_header X-Content-Type-Options nosniff always;
}
# Apache: only if AllowOverride permits it
<Directory "/var/www/public/uploads">
    php_admin_flag engine off
    Options -ExecCGI -Indexes
</Directory>

Note php_admin_flag in a server/directory block: it cannot be overridden by a dropped .htaccess, so an attacker cannot undo it. Relying on .htaccess alone fails on nginx (no .htaccess at all) and on any vhost with AllowOverride None.

Defense in depth after storage:

  • Serve with X-Content-Type-Options: nosniff so a browser cannot upgrade text/plain into an executable interpretation, and force Content-Disposition: attachment for anything that is not the image type you expect.
  • Serve through a script, not a static URL when downloads matter: the handler looks up an id in your database, checks authorization, then streams — a stolen path is useless without the row behind it.
  • Keep the extension derived from the sniffed MIME type, not from the client name. shell.php smuggled inside photo.jpg keeps .jpg and no handler will touch it.

Size limits must agree at every layer, or the error surfaces at the wrong one:

upload_max_filesize = 10M
post_max_size       = 12M      ; strictly larger: it also carries the form fields
max_file_uploads    = 20

nginx adds client_max_body_size 12m; and PHP's max_input_time must cover the transfer — a slow 10 MB upload over a bad connection is a timeout, not a size error, and returns UPLOAD_ERR_PARTIAL (code 3).

Two last habits. First, check $f['size'] in your own code even though INI limits exist: INI limits are per server, your promise to the user is per app, and a mismatch means users on one environment get errors the docs never mention. Second, move the file before you process it — move_uploaded_file() is constant-memory, while imagecreatefromjpeg() on a 30 MB file is not, so you can stream or re-encode from the destination with fopen() instead of holding it in RAM. Two more habits: delete on downstream failure — when the move succeeds but the database insert then fails, unlink($dest) before returning, or every rejected submission leaves an orphan nobody will clean up — and test through the real client: curl -F 'avatar=@cat.png' https://app.test/profile reproduces the multipart path with no browser involved, while php -i | grep upload_ shows the limits your production SAPI actually loads, since CLI, php-fpm, and the Apache module can each be reading a different php.ini.

With files landing in a predictable directory under your chosen names, the remaining question is what happens when the path itself is attacker input — the subject of Safe Path Handling. For the mechanics of reading files back, revisit Reading and Writing Files.

Quiz

1. A user submits a form with a file input but $_FILES is completely empty and $_POST has no fields either. What is the most likely cause?

Question 1 options

2. The browser reports the file type as image/png. Why is $_FILES['avatar']['type'] useless for security decisions?

Question 2 options

3. What extra guarantee does move_uploaded_file($tmp, $dest) provide over copy($tmp, $dest)?

Question 3 options

4. Uploads land in public/uploads/ and PHP is executing some of them. Which single change closes the hole most reliably?

Question 4 options

Flashcards

Question

What are the five entries PHP creates under each field name in $_FILES, and which of them can you trust?

Answer

name, type, tmp_name, error, size (each an array of arrays for multi-file inputs). Only tmp_name (a server-generated path), error (a PHP-assigned code) and size (measured by PHP) are trustworthy; name and type come straight from the client and are attacker-controlled input.

Question

Map UPLOAD_ERR codes 0, 1, 3, 4 to plain user-facing messages.

Answer

0 UPLOAD_ERR_OK → proceed; 1 UPLOAD_ERR_INI_SIZE → 'file exceeds the limit' (rejected by upload_max_filesize, file never reached your code); 3 UPLOAD_ERR_PARTIAL → 'transfer interrupted, please retry'; 4 UPLOAD_ERR_NO_FILE → 'please choose a file' (empty input, not an error).

Question

Why must upload_max_filesize be smaller than post_max_size, and what breaks when the request exceeds post_max_size?

Answer

post_max_size covers the whole multipart body — file parts plus form fields — so it must exceed the per-file cap. Exceeding it makes PHP discard $_POST and $_FILES entirely: no error code, no tmp_name, just empty arrays plus a warning, which is why empty-super-global bugs are diagnosed via CONTENT_LENGTH.

Question

Why generate the stored filename yourself instead of using $_FILES['name']?

Answer

The client name can contain path traversal (../../), arbitrary extensions, and characters the filesystem rejects, and it lets an attacker overwrite or guess existing files. Derive the extension from the server-side MIME sniff, prefix random_bytes()/bin2hex random names, and keep client strings for display only.

Question

Name three independent ways to stop an uploaded file from executing as PHP.

Answer

1) Store outside the document root so it is never served directly; 2) deny PHP execution for the uploads location (nginx `location ~ \.php$ { deny all; }`, or Apache php_admin_flag engine off — which .htaccess cannot override); 3) control the extension from a strict whitelist of sniffed MIME types, plus X-Content-Type-Options: nosniff when serving.

Revision Notes

Key Takeaways

  • 1. Without enctype="multipart/form-data" there is no $_FILES at all; with post_max_size exceeded there is neither $_POST nor $_FILES — diagnose by CONTENT_LENGTH
  • 2. $_FILES name and type are client claims; only tmp_name, error, and size come from PHP, so finfo plus a generated name is the only defensible pipeline
  • 3. move_uploaded_file verifies upload provenance through the SAPI and handles cross-filesystem moves — copy() and rename() skip that check
  • 4. A random stored name with an extension derived from the sniffed MIME type kills traversal, overwrite, and handler-guessing in one move
  • 5. Execution denial belongs at the server or outside the document root — .htaccess cannot be trusted on nginx or locked-down vhosts

Interview Tips

  • • "Walk me through a secure upload handler." — error code first, app-level size check, finfo MIME sniff, extension whitelist mapped from the MIME, random name, move_uploaded_file, fail closed on every return value; name each attack the step stops
  • • "What does $_FILES contain?" — five parallel arrays per field (name, type, tmp_name, error, size), arrays of arrays for multiple files; add that tmp_name exists only for this request
  • • "The user says their upload 'silently fails' — how do you debug?" — check whether $_FILES is empty (enctype or post_max_size), then the error code, then the web server's 413/client_max_body_size and max_input_time — three layers, in order
  • • "How would you let users download files safely?" — store outside the web root, look up an id, authorize against the database, then stream with a MIME you set, nosniff, and Content-Disposition; explain why a direct static URL is weaker

Cheat Sheet

Handling File Uploads

Form: <form method="post" enctype="multipart/form-data"> — without enctype, $_FILES is never populated.

$_FILES['field']: name (client), type (client), tmp_name (server), error (code), size (PHP) — parallel arrays; photos[] yields arrays of arrays.

Error codes: 0 OK · 1 INI_SIZE · 2 FORM_SIZE · 3 PARTIAL · 4 NO_FILE · 6 NO_TMP_DIR · 7 CANT_WRITE · 8 EXTENSION.

Limits: upload_max_filesize (per file, 2M) < post_max_size (whole body, 8M) — exceed post_max_size and $_POST+$_FILES vanish entirely. nginx client_max_body_size rejects even earlier with 413.

Validation pipeline:

if ($f['error'] !== UPLOAD_ERR_OK) { /* map code → message */ }
if ($f['size'] > APP_MAX)          { /* app-level cap */ }
$mime = (new finfo(FILEINFO_MIME_TYPE))->file($f['tmp_name']);  // not $f['type']
$ext  = $map[$mime] ?? null;                                     // whitelist, not blacklist
$name = bin2hex(random_bytes(16)) . '.' . $ext;                  // never $f['name']
if (!move_uploaded_file($f['tmp_name'], $dest)) { throw ... }

Storage rules: outside the document root when possible; otherwise deny \.php execution for that directory at the server (php_admin_flag engine off, not .htaccess); serve through a validating script with X-Content-Type-Options: nosniff. Temp files are auto-deleted at request end — no cleanup needed on rejection.

Continue Learning

Before You Start

Next Topics

Related Reading