
The MIME type and the file name announced by the browser are worth nothing: read the real type with finfo, check the dimensions, re-encode the image and regenerate the name with random_bytes. Store outside the web root, in a folder that executes nothing. For a base64 image, decode in strict mode and check the size before decoding.
Accepting a file sent by a visitor is one of the riskiest features a web application can have. Put a check in the wrong place and the server ends up executing what it just received. This tutorial covers the form, the processing, then the checks that actually hold, along with the tests that prove which ones do not. It also covers base64 image uploads, common with JavaScript croppers.
The form
<form action="upload.php" method="post" enctype="multipart/form-data">
<input type="hidden" name="MAX_FILE_SIZE" value="5242880">
<label for="photo">Photo</label>
<input type="file" name="photo" id="photo" accept="image/jpeg,image/png,image/gif,image/webp" required>
<button type="submit">Envoyer</button>
<p>Formats acceptés : JPEG, PNG, GIF, WebP. 5 Mo maximum.</p>
</form>The enctype="multipart/form-data" attribute is mandatory: without it, the browser only sends the file name and $_FILES stays empty. The accept attribute and the MAX_FILE_SIZE field improve the experience in the browser, they are not security checks, since a request can be crafted without going through the form.
What $_FILES contains
A file sent through the photo field lands in $_FILES['photo'], an associative array of five entries. Two of them come from the client and must never be used as a check.
| Key | Contents | Source |
|---|---|---|
name | Original file name, extension included | the client |
type | Announced MIME type | the client |
size | Size in bytes of the received file | the server |
tmp_name | Path to the temporary file on the server | the server |
error | Upload status code, 0 if everything went well | the server |
The received file is written to a temporary folder and deleted when the script ends. To keep it, you have to move it with move_uploaded_file(), or read it before the request finishes. The is_uploaded_file() function checks that the path really points to a file that came from an HTTP upload, which prevents anyone from aiming the processing at a system file.
The check that protects nothing
The pattern below turns up in most tutorials, including earlier versions of this one.
$autorises = ['jpg' => 'image/jpg', 'png' => 'image/png'];
$type = $_FILES['photo']['type']; // <- announced by the browser
if (in_array($type, $autorises)) {
move_uploaded_file($_FILES['photo']['tmp_name'], 'upload/' . $_FILES['photo']['name']);
}Two holes, measured on PHP 8.5.10.
The $_FILES['photo']['type'] value comes from the client. PHP never checks it. A crafted upload can announce anything:
type annoncé : image/png
type réel : text/x-php
getimagesize() : falseThe file is a PHP script, and the check lets it through.
The original name comes from the client too. Reusing it as is to build a path opens the door to several abuses:
'../../config.php' -> extension : php basename : config.php
'photo.php.png' -> extension : png basename : photo.php.png
'photo.png.php' -> extension : php basename : photo.png.php
'photo.pHp' -> extension : pHp basename : photo.pHpThe photo.php.png case is the nastiest: the extension pathinfo() sees really is png, so the check passes, but an Apache configured with AddHandler on .php can execute the file because of its middle extension. The photo.pHp case is a reminder that comparing extensions is case-sensitive.
The validation that holds
Three principles: the type is read from the content, not from what is announced, the name is regenerated, never reused, the image file is re-encoded.
<?php
declare(strict_types=1);
const TYPES_AUTORISES = [
'image/jpeg' => 'jpg',
'image/png' => 'png',
'image/gif' => 'gif',
'image/webp' => 'webp',
];
/**
* @return array{0: bool, 1: string} success and filename, or failure and reason
*/
function validerImage(
string $chemin,
int $maxOctets = 5_242_880,
int $maxPixels = 50_000_000,
): array {
if (!is_file($chemin)) {
return [false, 'fichier absent'];
}
$taille = filesize($chemin);
if ($taille === false || $taille === 0) {
return [false, 'fichier vide'];
}
if ($taille > $maxOctets) {
return [false, 'trop volumineux'];
}
// The real type, read from the bytes of the file
$mime = (new finfo(FILEINFO_MIME_TYPE))->file($chemin);
if (!isset(TYPES_AUTORISES[$mime])) {
return [false, "type refusé : {$mime}"];
}
$infos = @getimagesize($chemin);
if ($infos === false) {
return [false, 'image illisible'];
}
[$largeur, $hauteur] = $infos;
if ($largeur < 1 || $hauteur < 1) {
return [false, "dimensions invalides ({$largeur}x{$hauteur})"];
}
if ($largeur * $hauteur > $maxPixels) {
return [false, 'bombe de décompression'];
}
// Re-encoding: the only reliable defence against polyglot files
$image = @imagecreatefromstring(file_get_contents($chemin));
if ($image === false) {
return [false, 'décodage impossible'];
}
// Unpredictable name, written straight into the storage folder.
// Going through tempnam() then rename() would fail if /tmp and the storage
// sit on two different filesystems, which is the common case
// in a container.
$nom = bin2hex(random_bytes(16)) . '.' . TYPES_AUTORISES[$mime];
$destination = __DIR__ . '/../stockage/' . $nom;
$ok = match (TYPES_AUTORISES[$mime]) {
'jpg' => imagejpeg($image, $destination, 85),
'png' => imagepng($image, $destination),
'gif' => imagegif($image, $destination),
'webp' => imagewebp($image, $destination, 85),
};
if (!$ok) {
return [false, 'réencodage impossible'];
}
return [true, $nom];
}Handling the request, with the error codes:
<?php
declare(strict_types=1);
require __DIR__ . '/src/upload.php';
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
http_response_code(405);
exit('Méthode non autorisée');
}
if (!isset($_FILES['photo'])) {
http_response_code(400);
exit('Aucun fichier reçu');
}
$erreur = $_FILES['photo']['error'];
if ($erreur !== UPLOAD_ERR_OK) {
$message = match ($erreur) {
UPLOAD_ERR_INI_SIZE => 'Fichier plus grand que upload_max_filesize',
UPLOAD_ERR_FORM_SIZE => 'Fichier plus grand que MAX_FILE_SIZE',
UPLOAD_ERR_PARTIAL => 'Envoi interrompu',
UPLOAD_ERR_NO_FILE => 'Aucun fichier sélectionné',
UPLOAD_ERR_NO_TMP_DIR => 'Dossier temporaire absent sur le serveur',
UPLOAD_ERR_CANT_WRITE => 'Écriture impossible sur le disque',
UPLOAD_ERR_EXTENSION => 'Envoi bloqué par une extension PHP',
default => 'Erreur inconnue',
};
http_response_code(400);
exit($message);
}
// Guarantees the file really came from an HTTP upload
if (!is_uploaded_file($_FILES['photo']['tmp_name'])) {
http_response_code(400);
exit('Envoi invalide');
}
[$succes, $resultat] = validerImage($_FILES['photo']['tmp_name']);
if (!$succes) {
http_response_code(422);
exit('Fichier refusé : ' . $resultat);
}
echo 'Enregistré sous ', htmlspecialchars($resultat, ENT_QUOTES, 'UTF-8');What each check catches
Four files passed to validerImage(), on PHP 8.5.10 with GD 2.0.35:
| File uploaded | Result | Check triggered |
|---|---|---|
PHP script renamed to .png | rejected | real type: text/x-php |
| Polyglot: GIF header + PHP code | rejected | dimensions (0×0 or absurd) |
| Genuine 120×120 PNG | accepted | — |
| Genuine PNG with PHP appended to the end | accepted | re-encoded, the payload disappears |
The second line deserves a pause. A file starting with GIF89a followed by PHP code is identified as image/gif by finfo. The detection library trusts the signature of the first bytes, and that signature is correct. Only the dimension checks reject it: depending on the bytes that follow the header, getimagesize() reads 0×0 or absurd dimensions, and the pixel ceiling then refuses the file. A validation that stops at finfo lets it through.
The fourth row is the most common case: a perfectly valid image with a payload concatenated to it. It is accepted, and rightly so, since it is a real image, but the file that gets stored is the result of the re-encoding, not the original. The payload does not survive.
Storage counts as much as validation
No amount of validation replaces a storage folder that executes nothing. The folder has to sit outside the web root, and be served by a script that sends the right content type.
<IfModule mod_php.c>
php_flag engine off
</IfModule>
RemoveHandler .php .phtml .php3 .php4 .php5 .php7 .php8 .phar
RemoveType .php .phtml .php3 .php4 .php5 .php7 .php8 .phar
Header set X-Content-Type-Options "nosniff"location ^~ /stockage/ {
location ~ \.php$ { return 403; }
add_header X-Content-Type-Options "nosniff" always;
}The X-Content-Type-Options: nosniff header stops the browser from guessing a file’s type and executing HTML or JavaScript found inside an image.
The php.ini limits decide before your code does
foreach (['file_uploads','upload_max_filesize','post_max_size','max_file_uploads','memory_limit'] as $d) {
printf("%-20s %s\n", $d, ini_get($d));
}A file larger than post_max_size arrives with $_FILES and $_POST both completely empty: the isset($_FILES['photo']) test fails and the error message is misleading. That case has to be detected separately:
if ($_SERVER['REQUEST_METHOD'] === 'POST' && empty($_POST) && empty($_FILES)
&& (int) ($_SERVER['CONTENT_LENGTH'] ?? 0) > 0) {
http_response_code(413);
exit('Envoi plus grand que post_max_size (' . ini_get('post_max_size') . ')');
}This block comes with a condition we discovered while testing it: it only works if display_errors is off. PHP emits its own warning before running a single line of the script, and if that warning is displayed, the headers have already been sent.
display_errors = On -> HTTP 200 + « Warning: PHP Request Startup: POST Content-Length
of 8388808 bytes exceeds the limit of 6291456 bytes »
puis « Cannot set response code - headers already sent »
display_errors = Off -> HTTP 413 « Envoi plus grand que post_max_size (6M) »In production, display_errors should be Off anyway. Here is one more reason.
With upload_max_filesize = 5M and post_max_size = 6M, a 5.5 MB file gets past the first barrier and is stopped by the second, with UPLOAD_ERR_INI_SIZE and a 400 code. post_max_size has to stay above upload_max_filesize, otherwise the second value has no effect at all.
Receiving a base64 image
JavaScript croppers and captures from a <canvas> often send the image as a data:image/png;base64,… string rather than as a file. The validation principle does not change, but two extra traps appear.
The first: base64_decode() without its second argument accepts anything and returns random bytes rather than an error.
var_dump(base64_decode('ceci n est pas du base64 !!')); // string(9) "q..." — arbitrary bytes
var_dump(base64_decode('ceci n est pas du base64 !!', true)); // bool(false)The second: a base64 string weighs about a third more than the data it carries. Checking the size before decoding avoids allocating 40 MB only to then reject a 30 MB file.
<?php
declare(strict_types=1);
/**
* @return array{0: bool, 1: string}
*/
function enregistrerImageBase64(
string $entree,
string $dossier,
int $maxOctets = 2_097_152,
): array {
if (!preg_match('#^data:image/(jpeg|png|gif|webp);base64,#', $entree, $m)) {
return [false, 'préfixe data: absent ou type non autorisé'];
}
$encode = substr($entree, strlen($m[0]));
// Base64 weighs 4/3 of the data: filter before decoding
if (strlen($encode) > (int) ceil($maxOctets * 4 / 3)) {
return [false, 'trop volumineux (avant décodage)'];
}
$binaire = base64_decode($encode, true); // strict
if ($binaire === false) {
return [false, 'base64 invalide'];
}
if (strlen($binaire) > $maxOctets) {
return [false, 'trop volumineux'];
}
$mime = (new finfo(FILEINFO_MIME_TYPE))->buffer($binaire);
$extension = match ($mime) {
'image/jpeg' => 'jpg',
'image/png' => 'png',
'image/gif' => 'gif',
'image/webp' => 'webp',
default => null,
};
if ($extension === null) {
return [false, "type réel refusé : {$mime}"];
}
$infos = @getimagesizefromstring($binaire);
if ($infos === false || $infos[0] < 1 || $infos[1] < 1) {
return [false, 'image illisible'];
}
$nom = bin2hex(random_bytes(16)) . '.' . $extension;
if (file_put_contents(rtrim($dossier, '/') . '/' . $nom, $binaire, LOCK_EX) === false) {
return [false, 'écriture impossible'];
}
return [true, $nom];
}Results measured on five inputs:
| Input | Result | Reason |
|---|---|---|
| Valid 1×1 PNG | accepted | — |
PHP code announced as image/png | rejected | real type: text/x-php |
SVG with an onload | rejected | type not allowed |
| Arbitrary string | rejected | no data: prefix |
| Corrupted base64 | rejected | strict decoding failed |
SVG deserves to be excluded on purpose. It is an XML format that can carry JavaScript and external references. If you do have to accept it, run it through a dedicated sanitiser, and never serve it from the same domain as the application.
Two traps in the code you find everywhere
Naively splitting a data: string assumes the input is well formed:
$parties = explode(';base64,', $img);
$aux = explode('image/', $parties[0]);
$type = $aux[1]; // Undefined array key 1 if the input is arbitrary
$binaire = base64_decode($parties[1]); // Undefined array key 1, then a deprecation on nullAnd a file name generated with uniqid() is not unpredictable: the function derives from the clock, so its successive values can be guessed.
6a987fd6cde92 | 6a987fd6cde95 | 6a987fd6cde96bin2hex(random_bytes(16)) uses the system’s cryptographic generator. It is the only correct form when the name must not be guessable.
Key points
- The type and the name announced by the client are worth nothing: read the type from the content, regenerate the name.
finfois not enough on its own, a GIF polyglot fools it. Add the dimension check and the re-encoding.- Store outside the web root, in a folder that executes nothing.
- For base64: strict decoding, size check before decoding, SVG refused.
To send the stored file name back to the interface without opening a hole, see passing variables from PHP to JavaScript. A file of several tens of megabytes cannot be handled in one go: see reading large files in PHP. Once the file is saved, a 303 redirect stops the form being resubmitted on reload. And the validation class shown here follows the principles of OOP in PHP.
To go further, see image manipulation with GD and Imagick and the web development hub.
Common errors
image/png for a PHP script. The real type is read with finfo, never from $_FILES['x']['type'].photo.php.png really does have the png extension as far as pathinfo() is concerned, but a badly configured Apache can execute it because of the middle extension. Regenerate the name.GIF89a followed by PHP code is identified as image/gif. Only the dimension check rejects it.bin2hex(random_bytes(16)).true, the function accepts anything and returns arbitrary bytes instead of false.$_FILES and $_POST arrive empty. And the detection code only works if display_errors is Off: otherwise PHP prints its warning before your script and the headers are already sent.

