Subir archivos en PHP de forma segura

Subir archivos en PHP es fácil; hacerlo con seguridad exige unas cuantas comprobaciones que casi todos los tutoriales se saltan. Este ejemplo las hace todas y prueba con curl que una imagen se acepta y lo demás se rechaza.

El formulario

<form method="post" action="/subir.php" enctype="multipart/form-data">
  <input type="file" name="foto" accept="image/jpeg,image/png,image/webp">
  <button>Subir</button>
</form>

Sin enctype="multipart/form-data", el navegador no envía el archivo. El atributo accept filtra lo que se ofrece en el selector, pero no protege nada: cualquiera puede enviar la petición sin pasar por tu formulario.

El código que lo recibe

<?php
declare(strict_types=1);

// Recibe una imagen y la guarda FUERA de la carpeta pública, con un nombre nuevo.
header('Content-Type: application/json; charset=utf-8');

const MAX_BYTES = 1_000_000;
const TIPOS = ['image/jpeg' => 'jpg', 'image/png' => 'png', 'image/webp' => 'webp'];
const ALMACEN = __DIR__ . '/../almacen';   // no está dentro de la carpeta que sirve la web

function responder(int $codigo, array $datos): never
{
    http_response_code($codigo);
    echo json_encode($datos, JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR), "\n";
    exit;
}

$archivo = $_FILES['foto'] ?? null;
if (!is_array($archivo) || !is_int($archivo['error'] ?? null)) {
    responder(400, ['error' => 'Envía un único archivo en el campo «foto»']);
}

if ($archivo['error'] !== UPLOAD_ERR_OK) {
    $motivo = match ($archivo['error']) {
        UPLOAD_ERR_INI_SIZE, UPLOAD_ERR_FORM_SIZE => 'El archivo es demasiado grande',
        UPLOAD_ERR_NO_FILE => 'No llegó ningún archivo',
        default => 'La subida falló (código ' . $archivo['error'] . ')',
    };
    responder(400, ['error' => $motivo]);
}

if (!is_uploaded_file($archivo['tmp_name']) || filesize($archivo['tmp_name']) > MAX_BYTES) {
    responder(400, ['error' => 'El archivo es demasiado grande']);
}

// El tipo real, leyendo el contenido. El nombre y el tipo que manda el navegador no cuentan.
$tipo = (new finfo(FILEINFO_MIME_TYPE))->file($archivo['tmp_name']);
$extension = TIPOS[$tipo] ?? null;
if ($extension === null) {
    responder(415, ['error' => "Tipo no permitido: $tipo", 'nombre_enviado' => $archivo['name'], 'tipo_enviado' => $archivo['type']]);
}

if (!is_dir(ALMACEN)) {
    mkdir(ALMACEN, 0750, true);
}
$nombre = bin2hex(random_bytes(16)) . '.' . $extension;
move_uploaded_file($archivo['tmp_name'], ALMACEN . '/' . $nombre);

responder(201, ['guardado' => true, 'tipo' => $tipo, 'bytes' => filesize(ALMACEN . '/' . $nombre)]);

Las comprobaciones, en orden:

  1. Que llegó lo esperado: un solo archivo en el campo foto. Si alguien envía varios con el mismo nombre, $_FILES cambia de forma; el código lo rechaza en vez de confundirse.
  2. Que la subida no falló, mirando error: el más habitual es superar upload_max_filesize de php.ini (2 MB por omisión).
  3. Un tamaño máximo propio, aquí 1 MB.
  4. El tipo real, con finfo, que lee los primeros bytes del contenido. El nombre (name) y el tipo (type) los escribe quien envía, y pueden decir cualquier cosa.
  5. Un nombre nuevo y aleatorio, con la extensión que corresponde al tipo detectado. Nunca uses el nombre original para guardar.
  6. Una carpeta fuera de la pública, para que ningún archivo subido se pueda pedir con una URL, y mucho menos ejecutar.

Probarlo con curl

Una imagen PNG de verdad:

curl -F 'foto=@punto.png' …HTTP/1.1 201 Created
Content-Type: application/json; charset=utf-8

{"guardado":true,"tipo":"image\/png","bytes":70}

Un archivo de texto con nombre de foto y tipo de imagen en la petición:

curl -F 'foto=@foto.jpg;type=image/jpeg' …HTTP/1.1 415 Unsupported Media Type
Content-Type: application/json; charset=utf-8

{"error":"Tipo no permitido: text\/plain","nombre_enviado":"foto.jpg","tipo_enviado":"image\/jpeg"}

Un archivo de 3 MB, por encima del límite de PHP, y una petición sin archivo:

curl -F 'foto=@grande.png' …   (3 MB)HTTP/1.1 400 Bad Request
Content-Type: application/json; charset=utf-8

{"error":"El archivo es demasiado grande"}
curl …   (sin archivo)HTTP/1.1 400 Bad Request
Content-Type: application/json; charset=utf-8

{"error":"Envía un único archivo en el campo «foto»"}

En el almacén solo quedó la imagen buena, con su nombre aleatorio:

ls almacen/(32 cifras hexadecimales aleatorias).png
Por qué tanto cuidado. Si un archivo con código PHP acaba en una carpeta pública con extensión .php, o en una configuración que ejecute lo que encuentre, quien lo subió puede ejecutar código en tu servidor. Es una de las formas más comunes de tomar el control de una web. Además, mantén PHP al día: en octubre de 2026, el fallo crítico PHP2Shell afectaba al procesamiento de las subidas antes incluso de que llegaran a tu código.

Servir los archivos subidos

Como están fuera de la carpeta pública, se entregan con un script que comprueba antes si quien los pide tiene permiso, y los envía con readfile y la cabecera Content-Type del tipo detectado al subirlos (guárdalo en la base de datos junto al nombre). Para imágenes públicas y mucho tráfico, se suelen mover a un almacenamiento aparte, como un servicio de objetos en la nube.

Resumen

ComprobaciónCómo
La subida fue bien$_FILES['foto']['error'] === UPLOAD_ERR_OK
Tamañofilesize() y upload_max_filesize
Tipo real(new finfo(FILEINFO_MIME_TYPE))->file($tmp)
Nombrebin2hex(random_bytes(16)) y la extensión del tipo
Dóndefuera de la carpeta pública, con move_uploaded_file

Todo el código de esta página se ejecutó con PHP 8.5.12 en un contenedor limpio: la aplicación, con el servidor integrado de PHP (php -S) y sin acceso a la red, y las peticiones, con curl desde el mismo contenedor. Las respuestas están copiadas de esa ejecución.