Seguridad en PHP: los quince fallos que más se repiten en aplicaciones PHP, agrupados en tres bloques: lo que llega de fuera, la identidad y los secretos, y la configuración. Para cada uno, el patrón de código que hay que evitar, la forma segura comprobada con PHP 8.5 y cómo encontrarlo en tu propio proyecto.
PHP no es un lenguaje inseguro, pero es el más expuesto: está en una gran parte de las webs, y muchas aplicaciones se escribieron cuando las costumbres de hoy no existían. La buena noticia es que casi todos los fallos se repiten y casi todos se evitan con unas pocas reglas. Esta página los reúne. Cada uno tiene la misma estructura: qué es, el patrón de código que hay que evitar, la forma segura y cómo encontrarlo en un proyecto que ya existe.
Si estás aprendiendo PHP, cada lección del curso tiene su recuadro «Ojo con la seguridad» con estos mismos fallos en contexto. Y recuerda que el código seguro no basta si PHP o las bibliotecas tienen fallos conocidos: de eso trata Versiones y parches.
Lo que llega de fuera
1. Inyección SQL
Ocurre cuando un dato externo se pega dentro del texto de una consulta SQL. Lo que debía ser un valor pasa a formar parte de la orden, y quien lo envía puede cambiar lo que hace la consulta: leer otras tablas, saltarse una comprobación o modificar datos. Lleva más de veinte años entre los fallos más graves de la web.
El patrón que hay que evitar
$sql = "SELECT * FROM usuarios WHERE email = '" . $_POST['email'] . "'"; $pdo->query($sql);
La forma segura
Consultas preparadas siempre: la consulta lleva marcadores y los datos viajan aparte. Los marcadores solo sirven para valores; los nombres de columnas o el sentido de un ORDER BY se eligen de una lista cerrada. Aquí, una columna desconocida no llega a la consulta: se ordena por nombre:
<?php
declare(strict_types=1);
$pdo = new PDO('sqlite::memory:', options: [PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC]);
$pdo->exec('CREATE TABLE productos (nombre TEXT, precio INTEGER)');
$pdo->exec("INSERT INTO productos VALUES ('Café', 1250), ('Taza', 800), ('Té', 950)");
function listar(PDO $pdo, int $maximo, string $orden): array
{
$columnas = ['nombre' => 'nombre', 'precio' => 'precio'];
$columna = $columnas[$orden] ?? 'nombre';
$consulta = $pdo->prepare("SELECT nombre, precio FROM productos WHERE precio <= :maximo ORDER BY $columna");
$consulta->execute(['maximo' => $maximo]);
return $consulta->fetchAll();
}
print_r(array_column(listar($pdo, 1300, 'precio'), 'nombre'));
print_r(array_column(listar($pdo, 1300, 'cualquier cosa'), 'nombre'));salidaArray
(
[0] => Taza
[1] => Té
[2] => Café
)
Array
(
[0] => Café
[1] => Taza
[2] => Té
)Cómo encontrarlo en tu código: busca llamadas a query(, exec( o mysqli_query( cuyo argumento contenga $ o un punto de concatenación: grep -rnE "(query|exec|mysqli_query)\(.*(\$|\. )" src/. Cada resultado se revisa a mano.
2. XSS: escribir en la página lo que manda el visitante
Si un dato externo se escribe en el HTML sin escapar, el navegador lo interpreta como parte de la página, incluido código JavaScript. Ese código se ejecuta con la sesión del visitante, y puede leer sus datos o actuar en su nombre.
El patrón que hay que evitar
<p>Hola, <?= $_GET['nombre'] ?></p> <input value="<?= $busqueda ?>">
La forma segura
Escapar al escribir, y según el lugar: no es lo mismo el texto de un párrafo, un atributo, un valor dentro de JavaScript o un parámetro de una URL. Con un texto inocente pero lleno de caracteres especiales se ve la diferencia:
<?php
declare(strict_types=1);
function e(string $s): string
{
return htmlspecialchars($s, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
}
$texto = 'O\'Brien & "Cía" <ventas>';
echo "HTML o atributo: ", e($texto), "\n";
echo "Dentro de JS: ", json_encode($texto, JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT | JSON_THROW_ON_ERROR), "\n";
echo "En una URL: ", 'buscar.php?q=' . rawurlencode($texto), "\n";salidaHTML o atributo: O'Brien & "Cía" <ventas> Dentro de JS: "O\u0027Brien \u0026 \u0022C\u00eda\u0022 \u003Cventas\u003E" En una URL: buscar.php?q=O%27Brien%20%26%20%22C%C3%ADa%22%20%3Cventas%3E
Para HTML y atributos, htmlspecialchars con ENT_QUOTES (escapa también la comilla simple). Para pasar datos a JavaScript, json_encode con las opciones JSON_HEX_*. Para parámetros de URL, rawurlencode. Y para enlaces cuyo destino lo escribe un usuario, comprueba además que empiece por https://, como en el reto de la lección 7. Una cabecera Content-Security-Policy (punto 15) añade una segunda barrera.
Cómo encontrarlo en tu código: cualquier echo, print o <?= que escriba una variable sin pasar por tu función de escape. Los motores de plantillas como Twig o Blade escapan por omisión, y es una buena razón para usarlos.
3. CSRF: peticiones que el usuario no quiso hacer
Mientras alguien tiene la sesión abierta en tu web, otra página que visite puede intentar que su navegador envíe un formulario a la tuya, y el navegador adjuntará su cookie de sesión. Tu aplicación no distingue esa petición de una legítima.
El patrón que hay que evitar
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
cambiarCorreo($_SESSION['usuario'], $_POST['email']); // sin comprobar de dónde viene
}La forma segura
Un token aleatorio por sesión, escondido en cada formulario y comprobado con hash_equals en cada POST que cambie algo, más la cookie de sesión con SameSite=Lax. Las acciones que cambian datos van siempre por POST, nunca por GET. El código completo, funcionando, está en la lección 7 y en el proyecto.
Cómo encontrarlo en tu código: formularios con method="post" sin un campo de token, y manejadores de POST que no lo comprueban. Si usas un framework, comprueba que su protección no esté desactivada en alguna ruta.
4. Inclusión de archivos
include y require ejecutan el archivo que se les indica. Si la ruta depende de un dato externo, quien lo envía puede elegir qué archivo del servidor se ejecuta o se muestra, por ejemplo uno de configuración con contraseñas. Con allow_url_include activado, podría ser incluso un archivo de otro servidor.
El patrón que hay que evitar
include 'paginas/' . $_GET['pagina'] . '.php';
La forma segura
Nunca construyas una ruta de include con datos de fuera: usa una lista cerrada que traduzca nombres a archivos.
<?php
declare(strict_types=1);
function rutaDePagina(string $nombre): ?string
{
$paginas = [
'inicio' => __DIR__ . '/paginas/inicio.php',
'contacto' => __DIR__ . '/paginas/contacto.php',
];
return $paginas[$nombre] ?? null;
}
foreach (['inicio', 'contacto', 'precios', ''] as $pedida) {
$ruta = rutaDePagina($pedida);
echo mb_str_pad("«{$pedida}»", 12), $ruta === null ? "→ 404" : "→ " . basename($ruta), "\n";
}salida«inicio» → inicio.php «contacto» → contacto.php «precios» → 404 «» → 404
Y en php.ini, allow_url_include = Off, que es el valor por omisión.
Cómo encontrarlo en tu código: grep -rnE "(include|require)(_once)?\s*\(?.*\$" src/: toda inclusión con una variable en la ruta merece una mirada.
5. Subida de archivos
Un formulario de subida recibe archivos que elige otra persona: el nombre, la extensión y el tipo que dice el navegador los decide ella. El riesgo grave es que un archivo con código PHP acabe en una carpeta pública donde el servidor lo ejecute.
El patrón que hay que evitar
move_uploaded_file($_FILES['foto']['tmp_name'], 'public/subidas/' . $_FILES['foto']['name']);
La forma segura
- Comprueba el tipo real leyendo el contenido con
finfo, no el que manda el navegador ni la extensión. - Limita el tamaño (
upload_max_filesizey una comprobación propia). - Pon tú el nombre: uno aleatorio, con la extensión que corresponde al tipo detectado.
- Guarda los archivos fuera de la carpeta pública y sírvelos con un script que compruebe permisos, o en una carpeta donde el servidor no ejecute PHP.
<?php
declare(strict_types=1);
const TIPOS = ['image/jpeg' => 'jpg', 'image/png' => 'png', 'image/webp' => 'webp'];
function aceptarImagen(string $rutaTemporal, int $maximoBytes = 2_000_000): ?string
{
if (filesize($rutaTemporal) > $maximoBytes) {
return null;
}
$tipo = (new finfo(FILEINFO_MIME_TYPE))->file($rutaTemporal);
$extension = TIPOS[$tipo] ?? null;
return $extension === null ? null : bin2hex(random_bytes(16)) . '.' . $extension;
}
// Un PNG de verdad (1×1 píxel) y un archivo de texto que alguien llamó «foto.jpg»
$png = tempnam(sys_get_temp_dir(), 'sub');
file_put_contents($png, base64_decode('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg=='));
$falso = tempnam(sys_get_temp_dir(), 'sub');
file_put_contents($falso, "esto no es una imagen\n");
foreach (['imagen PNG' => $png, '«foto.jpg» de texto' => $falso] as $que => $ruta) {
$tipo = (new finfo(FILEINFO_MIME_TYPE))->file($ruta);
$nombre = aceptarImagen($ruta);
echo "$que: $tipo → ", $nombre === null ? 'rechazado' : 'aceptado', "\n";
}salidaimagen PNG: image/png → aceptado «foto.jpg» de texto: text/plain → rechazado
El nombre aceptado es aleatorio en cada ejecución, por eso aquí solo mostramos si se acepta o no. En octubre de 2026, el fallo crítico PHP2Shell afectaba precisamente al procesamiento de las subidas: aunque tu código sea correcto, mantener PHP al día también protege esta puerta.
Cómo encontrarlo en tu código: move_uploaded_file y $_FILES: comprueba en cada uso de dónde sale el nombre, dónde se guarda y si se valida el contenido.
6. Deserialización de datos externos
unserialize reconstruye objetos a partir de un texto, y es el texto el que decide de qué clase son. Con datos externos, quien los envía puede crear objetos de clases de tu aplicación o de tus bibliotecas cuyos métodos mágicos (__destruct, __wakeup) hagan cosas que no esperabas. Se llama inyección de objetos, y ha afectado a muchos CMS y plugins.
El patrón que hay que evitar
$preferencias = unserialize($_COOKIE['prefs']);
La forma segura
Para datos que vienen de fuera, JSON: json_decode($texto, true, flags: JSON_THROW_ON_ERROR) solo produce arrays, textos, números y booleanos. Si no puedes evitar unserialize, pásale ['allowed_classes' => false]. La lección 5 lo muestra funcionando.
Cómo encontrarlo en tu código: grep -rn "unserialize(" src/. Cuidado también con las rutas que empiezan por phar://: algunas funciones de archivos deserializan los metadatos de esos archivos, así que nunca dejes que un dato externo elija el esquema de una ruta.
7. Órdenes del sistema
exec, system, shell_exec, passthru y las comillas invertidas pasan una orden al shell del sistema. Si esa orden incluye un dato externo, el shell puede interpretar parte del dato como otra orden.
El patrón que hay que evitar
exec("convert " . $_POST['archivo'] . " miniatura.png");La forma segura
Lo mejor es no llamar al shell: casi siempre hay una función o una extensión de PHP que hace lo mismo. Si no la hay, proc_open acepta la orden como array (desde PHP 7.4): el programa recibe cada argumento tal cual, sin shell de por medio.
<?php
declare(strict_types=1);
$dir = sys_get_temp_dir() . '/demo-ordenes';
@mkdir($dir);
$nombre = "informe final; versión 2.txt";
file_put_contents("$dir/$nombre", "hola\n");
$proceso = proc_open(['wc', '-c', $nombre], [1 => ['pipe', 'w'], 2 => ['pipe', 'w']], $tuberias, $dir);
echo stream_get_contents($tuberias[1]);
echo "código de salida: ", proc_close($proceso), "\n";salida5 informe final; versión 2.txt código de salida: 0
El nombre lleva un espacio y un punto y coma, que en un shell separarían argumentos y órdenes. Pasado en un array, wc lo recibe como un solo nombre de archivo. Si mantienes código antiguo que usa exec, cada dato externo debe pasar por escapeshellarg.
Cómo encontrarlo en tu código: grep -rnE "(exec|system|shell_exec|passthru|popen|proc_open)\(|\`" src/.
8. SSRF: peticiones que tu servidor hace por encargo
Si tu aplicación descarga una URL que le da el usuario (una imagen por dirección, la vista previa de un enlace, un webhook), quien la elige puede apuntar a direcciones que solo tu servidor alcanza: servicios internos, paneles de administración o los metadatos de la nube, que a veces contienen credenciales.
El patrón que hay que evitar
$imagen = file_get_contents($_POST['url']);
La forma segura
Acepta solo https, a ser posible solo una lista de dominios conocidos y, si tiene que ser cualquiera, resuelve el nombre y rechaza las direcciones privadas y reservadas antes de conectar. PHP 8.5 trae la extensión Uri, que analiza las URL siguiendo el estándar:
<?php
declare(strict_types=1);
function urlPermitida(string $texto, array $dominios): bool
{
$uri = Uri\Rfc3986\Uri::parse($texto);
if ($uri === null || $uri->getScheme() !== 'https') {
return false;
}
return in_array($uri->getHost(), $dominios, true);
}
function ipPublica(string $ip): bool
{
return filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE) !== false;
}
$permitidos = ['imagenes.ejemplo.com'];
foreach (['https://imagenes.ejemplo.com/a.png', 'http://imagenes.ejemplo.com/a.png', 'https://otra.ejemplo.net/b.png', 'no es una url'] as $u) {
echo str_pad($u, 38), urlPermitida($u, $permitidos) ? 'permitida' : 'rechazada', "\n";
}
foreach (['93.184.215.14', '10.0.0.5', '127.0.0.1', '169.254.169.254', '192.168.1.10'] as $ip) {
echo str_pad($ip, 18), ipPublica($ip) ? 'pública' : 'privada o reservada', "\n";
}salidahttps://imagenes.ejemplo.com/a.png permitida http://imagenes.ejemplo.com/a.png rechazada https://otra.ejemplo.net/b.png rechazada no es una url rechazada 93.184.215.14 pública 10.0.0.5 privada o reservada 127.0.0.1 privada o reservada 169.254.169.254 privada o reservada 192.168.1.10 privada o reservada
La comprobación de la IP debe hacerse sobre la dirección a la que realmente te conectas, después de resolver el nombre, y sin seguir redirecciones automáticamente: una URL inofensiva puede redirigir a una dirección interna.
Cómo encontrarlo en tu código: file_get_contents, fopen, curl_init o un cliente HTTP como Guzzle con una URL que no sea fija.
9. XML con entidades externas (XXE)
XML permite declarar «entidades» que se sustituyen por el contenido de un archivo o de una URL. Un analizador que las procese al leer un XML externo puede acabar leyendo archivos del servidor.
El patrón que hay que evitar
$doc = new DOMDocument(); $doc->loadXML($xmlDelUsuario, LIBXML_NOENT | LIBXML_DTDLOAD);
La forma segura
Desde libxml 2.9, que es la que usa cualquier PHP actual, las entidades externas no se cargan por omisión. El peligro está en activarlas: no uses LIBXML_NOENT ni LIBXML_DTDLOAD con XML que no controlas, y si recibes datos de terceros, valora si JSON no te sirve igual.
Cómo encontrarlo en tu código: grep -rnE "LIBXML_(NOENT|DTDLOAD|DTDATTR)" src/.
Identidad, secretos y sesiones
10. Contraseñas guardadas mal
El patrón que hay que evitar
$guardada = md5($clave); // o sha1(), o hash('sha256', ...)
if (md5($intento) == $guardada) { /* ... */ }La forma segura
password_hash para guardar, password_verify para comprobar y password_needs_rehash para actualizar con los años. Los resúmenes rápidos como MD5 o SHA-256 se prueban por miles de millones por segundo, y sin sal dos usuarios con la misma contraseña tienen el mismo resumen. Exige contraseñas largas, limita los intentos de inicio de sesión y, en cuentas importantes, ofrece un segundo factor. Explicado y probado en la lección 7.
Cómo encontrarlo en tu código: grep -rnE "(md5|sha1|crypt)\(" src/ cerca de cualquier cosa que se llame password, clave o pass.
11. Sesiones mal configuradas
La cookie de sesión es la llave de la cuenta. Si un script de la página puede leerla, si viaja sin cifrar o si un atacante puede imponer un identificador que conoce antes de que la víctima inicie sesión, la cuenta queda expuesta.
La forma segura
session.use_strict_mode = 1 session.use_only_cookies = 1 session.cookie_httponly = 1 session.cookie_secure = 1 session.cookie_samesite = Lax
Y en el código, session_regenerate_id(true) justo después de iniciar sesión y al cambiar de privilegios, y session_destroy() más el borrado de la cookie al cerrarla. En la sesión guarda el id del usuario, no sus datos.
12. Comparaciones laxas y comparaciones que delatan
== convierte los tipos antes de comparar, y hay textos distintos que resultan «iguales», como vimos con los magic hashes en la lección 2. Además, una comparación normal termina en el primer carácter distinto, y medir cuánto tarda da pistas sobre cuántos caracteres acertó quien prueba.
El patrón que hay que evitar
if ($tokenRecibido == $tokenGuardado) { /* ... */ }
if (in_array($rol, $rolesPermitidos)) { /* ... */ }La forma segura
=== en todas partes, in_array(..., true) con el modo estricto, y hash_equals para comparar tokens, firmas o cualquier secreto: tarda lo mismo acierte o falle.
13. Números aleatorios predecibles
rand, mt_rand, uniqid o microtime no sirven para nada que deba ser secreto: sus valores se pueden predecir o acotar. Para tokens, enlaces de recuperación de contraseña, nombres de archivo o códigos de verificación:
<?php
declare(strict_types=1);
$token = bin2hex(random_bytes(32));
$codigo = random_int(100_000, 999_999);
$r = new Random\Randomizer();
$clave = $r->getBytesFromString('abcdefghjkmnpqrstuvwxyz23456789', 12);
echo strlen($token), " caracteres de token, código de ", strlen((string) $codigo), " cifras, clave de ", strlen($clave), "\n";
var_dump(ctype_xdigit($token));salida64 caracteres de token, código de 6 cifras, clave de 12 bool(true)
Los tres usan el generador criptográfico del sistema. Aquí solo mostramos su forma, porque los valores cambian en cada ejecución.
Cómo encontrarlo en tu código: grep -rnE "(rand|mt_rand|uniqid|lcg_value)\(" src/ y revisa si alguno genera algo secreto.
Configuración y entorno
14. Un PHP de desarrollo en producción
Mostrar los errores al visitante revela rutas, consultas y a veces contraseñas; anunciar la versión de PHP ayuda a buscar fallos conocidos de esa versión. Este programa revisa la configuración con la que se ejecuta. Lo lanzamos dos veces: sin ningún php.ini, como en la imagen oficial de Docker, y con la plantilla de producción que trae PHP:
<?php
declare(strict_types=1);
$esperado = [
'display_errors' => ['', '0', 'Off'],
'log_errors' => ['1', 'On'],
'expose_php' => ['', '0', 'Off'],
'allow_url_include' => ['', '0', 'Off'],
'session.use_strict_mode' => ['1', 'On'],
'session.cookie_httponly' => ['1', 'On'],
];
foreach ($esperado as $opcion => $buenos) {
$valor = (string) ini_get($opcion);
$ok = in_array($valor, $buenos, true);
printf("%s %-26s %s\n", $ok ? 'bien ' : 'REVISA', $opcion, $valor === '' ? '(vacío)' : $valor);
}php revisar.php (configuración por omisión)REVISA display_errors 1 REVISA log_errors 0 REVISA expose_php 1 bien allow_url_include 0 REVISA session.use_strict_mode 0 REVISA session.cookie_httponly 0
php -c php.ini-production revisar.phpbien display_errors (vacío) bien log_errors 1 REVISA expose_php 1 bien allow_url_include (vacío) REVISA session.use_strict_mode 0 REVISA session.cookie_httponly (vacío)
Sin ningún php.ini, PHP muestra los errores, no los guarda en ningún registro y anuncia su versión: es la configuración pensada para desarrollar. La plantilla php.ini-production que trae PHP corrige los errores, pero deja expose_php activado y no activa las opciones de sesión. En un servidor público, parte de esa plantilla y cambia esas tres líneas. Otras dos medidas: el código no debe poder escribirse por el usuario con el que corre PHP, y los secretos (contraseñas de bases de datos, claves de API) van en variables de entorno o en archivos fuera de la carpeta pública, nunca en el repositorio.
disable_functions y open_basedir no son un muro. Desactivar funciones como exec o limitar las carpetas con open_basedir estorba a un atacante, pero no lo detiene: a lo largo de los años se han publicado muchas formas de saltarse ambas. Son una capa más, no un sustituto de corregir el código y de aislar el servidor (un usuario sin privilegios, contenedores, permisos mínimos).15. Funciones peligrosas y cabeceras que faltan
Algunas funciones convierten datos en código o en variables, y casi nunca hacen falta:
| Función | El riesgo | En su lugar |
|---|---|---|
eval() | ejecuta un texto como código PHP | nunca con datos externos; casi nunca en general |
extract() | convierte claves de un array en variables, y puede pisar las tuyas | leer las claves que necesitas |
$$nombre | variables variables: el nombre lo decide un dato | un array asociativo |
assert() con texto | ejecutaba el texto como código (eliminado en PHP 8) | excepciones |
preg_replace con /e | evaluaba el reemplazo (eliminado en PHP 7) | preg_replace_callback |
Y unas cuantas cabeceras HTTP que el navegador entiende como instrucciones de seguridad. Se pueden enviar desde PHP con header() o, mejor, desde la configuración del servidor web:
| Cabecera | Qué hace |
|---|---|
Content-Security-Policy | dice de dónde puede cargar scripts la página; frena muchos XSS aunque se cuele uno |
Strict-Transport-Security | obliga al navegador a usar siempre HTTPS con tu dominio |
X-Content-Type-Options: nosniff | impide que el navegador «adivine» que un archivo es un script |
Referrer-Policy | limita qué parte de tu URL se envía a otras webs |
frame-ancestors (en la CSP) | impide que otra web meta la tuya en un marco para engañar al usuario |
Cómo revisar un proyecto que ya existe
- Actualiza primero. PHP con soporte y dependencias sin avisos:
composer audit(lección 6) y Versiones y parches. - Pasa un analizador estático. PHPStan en un nivel alto encuentra datos
mixedsin comprobar, como en el proyecto; algunas herramientas, como Psalm, hacen además análisis de contaminación (taint analysis): siguen cada dato externo hasta una consulta, unechoo uninclude. - Busca los patrones de esta página con
grepy revisa cada resultado a mano. - Revisa la configuración del servidor con un programa como el del punto 14.
- Mira los registros. Si sospechas que alguien ya entró, nuestra guía Cómo saber si atacaron tu WordPress sirve para cualquier aplicación PHP.
Lista de comprobación
| Comprobación | |
|---|---|
| ☐ | Todas las consultas SQL usan marcadores; columnas y orden salen de listas cerradas |
| ☐ | Todo lo que se escribe en la página pasa por una función de escape adecuada al contexto |
| ☐ | Cada formulario que cambia datos lleva un token CSRF comprobado con hash_equals |
| ☐ | Ningún include ni ruta de archivo se construye con datos externos |
| ☐ | Las subidas se validan con finfo, se renombran y se guardan fuera de la carpeta pública |
| ☐ | Ningún dato externo llega a unserialize; se usa JSON |
| ☐ | No se llama al shell, o se hace con proc_open y un array |
| ☐ | Las URL que el servidor descarga se validan: https, dominios permitidos, sin IP privadas |
| ☐ | Contraseñas con password_hash; inicio de sesión con límite de intentos |
| ☐ | Cookie de sesión con HttpOnly, Secure y SameSite; identificador regenerado al entrar |
| ☐ | Secretos con random_bytes o random_int, nunca con rand o uniqid |
| ☐ | display_errors = Off, expose_php = Off y registros activados en producción |
| ☐ | Solo la carpeta public/ es accesible desde la web |
| ☐ | PHP con soporte de seguridad y composer audit sin avisos |
| ☐ | Cabeceras de seguridad configuradas, empezando por HTTPS y Content-Security-Policy |
El código de la forma segura de cada fallo se ejecutó con PHP 8.5.12 en un contenedor limpio, sin acceso a la red; las salidas están copiadas de esa ejecución. Los fragmentos marcados como «el patrón que hay que evitar» se muestran para reconocerlos y no se ejecutaron. Esta página explica los fallos para corregirlos, no cómo aprovecharlos.
