Seguridad en PHP
En esta lección
- Validar entradas, escapar salidas
- XSS: código ajeno en tu página
- Inyección SQL: tus datos convertidos en consulta
- Contraseñas: nunca en texto plano
- CSRF: peticiones hechas en tu nombre
- Sesiones seguras
- Subida de archivos segura
- Errores: muéstralos solo en desarrollo
- Lista de comprobación
- Errores frecuentes
- Resumen
Una web que funciona no es una web segura. En cuanto publicas una página con formularios, cualquiera en internet puede enviarle lo que quiera: texto con código, contraseñas de prueba, archivos disfrazados. La regla de oro es una sola: nunca te fíes de los datos que llegan del exterior (formularios, URL, cookies, archivos subidos, cabeceras). En esta lección verás los ataques más habituales contra una aplicación PHP y, para cada uno, la defensa que se usa hoy: XSS, inyección SQL, contraseñas mal guardadas, CSRF, sesiones robadas, subidas peligrosas y errores que revelan demasiado.
Varias de estas defensas ya han aparecido de pasada en otras lecciones: validar formularios, sesiones y cookies, ficheros y subidas y PDO. Aquí las reunimos con el por qué de cada una y añadimos las que faltan.
Validar entradas, escapar salidas
Hay dos momentos en los que proteges los datos, y no se sustituyen:
| Momento | Qué haces | Ejemplo |
|---|---|---|
| Al recibir | Validar: comprobar que el dato es lo que esperas | Un teléfono tiene 9 dígitos |
| Al usarlo | Escapar o parametrizar según el destino | htmlspecialchars en HTML, consulta preparada en SQL |
Cada destino tiene sus propios caracteres peligrosos: lo que es inofensivo en HTML puede ser un ataque en SQL. Por eso se protege en el sitio donde se usa el dato, no una sola vez al entrar.
XSS: código ajeno en tu página
El XSS (Cross-Site Scripting) ocurre cuando una web imprime texto de un usuario sin escapar y el navegador lo ejecuta como código. Es el fallo más común del libro de visitas, del buscador y de los comentarios:
<?php
// MAL: imprime lo que llegue tal cual
echo "<p>Resultados para: {$_GET['q']}</p>";
Si alguien comparte un enlace con ?q=<script>...</script>, ese script se ejecuta en el navegador de quien lo abra, con acceso a la página y a sus datos. La defensa es escapar al imprimir con htmlspecialchars(), que convierte <, >, & y las comillas en entidades:
<?php
$comentario = '<script>alert("hola")</script>';
echo htmlspecialchars($comentario);
// <script>alert("hola")</script>
Para no escribirlo cada vez, crea un atajo:
<?php
function e(?string $texto): string
{
return htmlspecialchars($texto ?? '', ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
}
echo '<p>Resultados para: ', e($_GET['q'] ?? ''), '</p>';
echo '<input name="busqueda" value="', e($_GET['q'] ?? ''), '">';
Reglas prácticas:
- Escapa todo lo que no hayas escrito tú, venga de un formulario, de la base de datos o de una API.
- Escapa al imprimir, no al guardar. En la base de datos va el texto original.
- Pon comillas en los atributos (
value="..."): conENT_QUOTES, las comillas del dato quedan escapadas y no pueden cerrar el atributo. - En URLs, usa
urlencode()para cada parámetro, y no pongas en unhrefuna dirección de usuario sin comprobar que empieza porhttps://ohttp://(unjavascript:...también ejecuta código).
Nota: cuando un campo debe permitir algo de formato (negritas, enlaces), no intentes filtrar HTML a mano. Usa un formato como Markdown o una librería de saneado especializada.
Inyección SQL: tus datos convertidos en consulta
Si construyes una consulta pegando texto del usuario, esa persona puede escribir SQL. Veámoslo con una tabla de usuarios y un buscador. Usamos SQLite en memoria para poder probarlo sin servidor:
<?php
$pdo = new PDO('sqlite::memory:', options: [PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION]);
$pdo->exec("CREATE TABLE usuarios (id INTEGER PRIMARY KEY, nombre TEXT, rol TEXT)");
$pdo->exec("INSERT INTO usuarios (nombre, rol) VALUES ('marta', 'socia'), ('admin', 'admin')");
$nombre = "x' OR '1'='1"; // lo que escribe un atacante en el campo
$sql = "SELECT nombre, rol FROM usuarios WHERE nombre = '$nombre'";
echo $sql, "\n";
// SELECT nombre, rol FROM usuarios WHERE nombre = 'x' OR '1'='1'
print_r($pdo->query($sql)->fetchAll(PDO::FETCH_ASSOC));
// Devuelve dos filas: marta (socia) y admin (admin)
La comilla del atacante cierra la cadena y OR '1'='1' hace la condición siempre verdadera: la consulta devuelve a todos los usuarios. Con otros trozos de SQL podría leer otras tablas, borrar datos o saltarse un login.
La defensa es la consulta preparada, que ya conoces de PDO: la consulta y los datos viajan por separado y el valor nunca se interpreta como SQL.
<?php
$consulta = $pdo->prepare('SELECT nombre, rol FROM usuarios WHERE nombre = ?');
$consulta->execute(["x' OR '1'='1"]);
var_dump($consulta->fetchAll()); // array(0) { } (nadie se llama así)
$consulta->execute(['marta']);
print_r($consulta->fetch(PDO::FETCH_ASSOC));
// Array ( [nombre] => marta [rol] => socia )
El mismo texto malicioso es ahora solo un nombre raro que no existe. Algunas precisiones:
- Siempre que haya un dato externo, usa
prepare()yexecute(). También enUPDATE,DELETEeINSERT. - Escapar a mano no es la solución. Funciones antiguas como
addslashes()omysqli_real_escape_string()fallan en casos que las consultas preparadas resuelven por diseño. - Los nombres de tablas y columnas no se pueden parametrizar. Si el usuario elige “ordenar por”, compara con una lista permitida (
in_array($campo, ['nombre', 'km'], true)) y construye la consulta con el valor de esa lista, nunca con el del usuario. - Con MySQL, desactiva las preparaciones emuladas (
PDO::ATTR_EMULATE_PREPARES => false), como viste en la conexión con PDO, para que las consultas se preparen de verdad. - El usuario de la base de datos de tu web debe tener los permisos mínimos (por ejemplo, sin
DROPni acceso a otras bases de datos).
Contraseñas: nunca en texto plano
Guardar una contraseña tal cual significa que, el día que alguien robe tu base de datos, tendrá las claves de todos tus usuarios, que probablemente las usan en otros sitios. Se guarda un hash: el resultado de una función de un solo sentido. Para contraseñas, PHP trae password_hash() y password_verify():
<?php
$hash = password_hash('Escalada2026', PASSWORD_DEFAULT);
echo strlen($hash), ' ', substr($hash, 0, 7), "\n"; // 60 $2y$12$
var_dump(password_verify('Escalada2026', $hash)); // bool(true)
var_dump(password_verify('otra', $hash)); // bool(false)
Por qué es la forma correcta:
- Usa un algoritmo deliberadamente lento (bcrypt por defecto) para que probar millones de claves sea inviable.
- Añade una sal aleatoria a cada hash: dos usuarios con la misma contraseña tienen hashes distintos. El hash lleva dentro la sal y el coste, así que
password_verifyno necesita nada más. PASSWORD_DEFAULTcambiará de algoritmo cuando se recomiende uno mejor.
Compara con las funciones antiguas: md5('Escalada2026') da siempre e898af24add5a206cbbc4390e1eabe47, es rapidísima y existen tablas con miles de millones de resultados ya calculados. No uses md5(), sha1() ni hash('sha256', ...) para contraseñas.
Buenas prácticas:
- Columna de al menos 255 caracteres (
VARCHAR(255)) para el hash: el algoritmo puede cambiar y alargarse. - Nunca compares hashes con
==. Usapassword_verify. - Actualiza hashes viejos al iniciar sesión, con
password_needs_rehash:
<?php
if (password_verify($clave, $hashGuardado)) {
if (password_needs_rehash($hashGuardado, PASSWORD_DEFAULT)) {
$nuevoHash = password_hash($clave, PASSWORD_DEFAULT);
// UPDATE usuarios SET clave = ? WHERE id = ?
}
}
- Mismo mensaje para usuario y clave incorrectos (“Datos incorrectos”): así no ayudas a descubrir qué usuarios existen.
- Limita los intentos (bloqueo temporal o espera creciente) para frenar la fuerza bruta.
CSRF: peticiones hechas en tu nombre
Imagina que estás conectado a la web de tu gimnasio y abres otra pestaña con una página maliciosa. Esa página contiene un formulario oculto que apunta a https://gimnasio.example/baja.php y se envía solo. Tu navegador adjunta tu cookie de sesión y el servidor cree que eres tú quien pide la baja. Ese ataque es el CSRF (Cross-Site Request Forgery).
La defensa clásica es un token CSRF: un valor aleatorio que el servidor guarda en tu sesión y pone en un campo oculto del formulario. Al recibir el POST, comprueba que el token enviado coincide con el guardado. Una página ajena no puede conocerlo.
<?php
function token_csrf(): string
{
if (empty($_SESSION['csrf'])) {
$_SESSION['csrf'] = bin2hex(random_bytes(32)); // 64 caracteres hexadecimales
}
return $_SESSION['csrf'];
}
function csrf_valido(?string $recibido): bool
{
return is_string($recibido)
&& isset($_SESSION['csrf'])
&& hash_equals($_SESSION['csrf'], $recibido);
}
random_bytes()genera bytes aleatorios criptográficamente seguros. No usesrand()niuniqid()para esto: son predecibles.hash_equals()compara dos textos en tiempo constante, para que un atacante no deduzca el token midiendo cuánto tarda la comparación.
En el formulario, el token va como campo oculto:
<form method="post" action="baja.php">
<input type="hidden" name="csrf" value="<?= e(token_csrf()) ?>">
<button>Darme de baja</button>
</form>
Y al recibir el POST lo compruebas antes de hacer nada:
<?php
// baja.php
session_start();
if ($_SERVER['REQUEST_METHOD'] !== 'POST' || !csrf_valido($_POST['csrf'] ?? null)) {
http_response_code(403);
exit('Petición no válida.');
}
// aquí ya es seguro continuar
Reglas del CSRF:
- Toda acción que cambia datos (borrar, editar, comprar, cerrar sesión) debe hacerse por POST, nunca por un enlace GET, y llevar token.
- Un token por sesión basta, aunque conviene renovarlo tras el login.
- La cookie con
SameSite=Lax(siguiente apartado) añade una segunda barrera: el navegador no envía la cookie en un POST que viene de otra web.
Sesiones seguras
La sesión se identifica con una cookie (PHPSESSID): quien la tenga, es el usuario. Cuida tres cosas.
1. Cambiar el identificador al iniciar sesión
Un ataque llamado fijación de sesión consiste en que el atacante fuerza a la víctima a usar un identificador que él ya conoce; cuando la víctima inicia sesión, el atacante entra con ese mismo identificador. Se evita generando uno nuevo en el momento del login:
<?php
if (password_verify($clave, $hash)) {
session_regenerate_id(true); // nuevo ID; true borra el antiguo
$_SESSION['usuario'] = 'marta';
$_SESSION['csrf'] = bin2hex(random_bytes(32)); // y token nuevo
}
Haz lo mismo cuando cambien los privilegios del usuario (por ejemplo, al pasar a administrador).
2. Cookie con opciones seguras
<?php
session_set_cookie_params([
'lifetime' => 0, // se borra al cerrar el navegador
'path' => '/',
'secure' => true, // solo por HTTPS (false en http://localhost)
'httponly' => true, // JavaScript no puede leerla: protege del robo vía XSS
'samesite' => 'Lax', // no se envía en POST desde otras webs
]);
session_start();
| Opción | Qué evita |
|---|---|
httponly | Que un XSS robe la cookie con document.cookie |
secure | Que la cookie viaje sin cifrar |
samesite | Que otras webs aprovechen tu cookie (CSRF) |
Además, en el php.ini conviene session.use_strict_mode = 1, para que PHP rechace identificadores que él no ha creado, y usar siempre HTTPS en producción.
3. Cerrar la sesión bien
Al cerrar sesión, vacía los datos, destruye la sesión y borra la cookie. El código completo está en sesiones y cookies. Si quieres, caduca también la sesión tras un rato de inactividad.
Cuidado: nunca pases el identificador de sesión por la URL (
?PHPSESSID=...). PHP ya lo evita por defecto consession.use_only_cookies; no lo desactives.
Subida de archivos segura
Una subida mal protegida permite que alguien suba foto.php y la visite para ejecutar su código en tu servidor. Las defensas, resumidas (el ejemplo completo está en ficheros y subidas):
- Comprueba
$_FILES['campo']['error']antes de nada. - Limita el tamaño en tu código.
- Detecta el tipo real por el contenido, no por la extensión ni por lo que dice el navegador. Aquí, un archivo PHP disfrazado de
.jpgy una imagen PNG de verdad:
<?php
$finfo = new finfo(FILEINFO_MIME_TYPE);
echo $finfo->file('/tmp/falsa.jpg'), "\n"; // text/x-php
echo $finfo->file('/tmp/ok.png'), "\n"; // image/png
- Usa una lista permitida de tipos y decide tú la extensión.
- Genera un nombre nuevo y aleatorio (
bin2hex(random_bytes(16))). El nombre original puede contener../o llamarsex.php. Si lo necesitas para mostrarlo, guárdalo aparte y escápalo. Conbasename()te quedas solo con el último trozo:basename('../../etc/passwd')devuelvepasswd. - Guarda fuera de la carpeta pública si es posible y, si no, configura el servidor para que no ejecute PHP en la carpeta de subidas.
- Para imágenes, comprueba además que
getimagesize()no devuelvefalse.
Errores: muéstralos solo en desarrollo
Un mensaje de error de PHP o de PDO puede revelar rutas del servidor, nombres de tablas e incluso datos de conexión. En tu ordenador quieres ver los errores; en la web pública, no:
| Entorno | display_errors | log_errors |
|---|---|---|
| Desarrollo (tu equipo) | On | On |
| Producción (web pública) | Off | On |
En php.ini:
display_errors = Off
log_errors = On
error_log = /var/log/php_errors.log
error_reporting = E_ALL
El usuario ve una página de error genérica (“Algo ha fallado”) y tú lees los detalles en el registro. Con PDO, captura la excepción y no muestres $e->getMessage() en la página:
<?php
try {
$pdo = new PDO($dsn, $usuario, $clave, [PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION]);
} catch (PDOException $e) {
error_log('Fallo de conexión: ' . $e->getMessage()); // al registro
http_response_code(500);
exit('Ahora mismo no podemos atenderte. Inténtalo más tarde.');
}
Más sobre esto en excepciones. Y, antes de publicar, elimina phpinfo() y los archivos de prueba (info.php, prueba.php, copias .bak).
Lista de comprobación
Antes de dar por buena una página, repasa:
- ¿Todo dato externo que imprimo pasa por
e()ohtmlspecialchars? - ¿Todas mis consultas con datos usan
prepareyexecute? - ¿Guardo las contraseñas con
password_hash? - ¿Los formularios que cambian algo van por POST con token CSRF?
- ¿Regenero el ID de sesión al iniciar sesión y la cookie es
httponlyysamesite? - ¿Las subidas validan tipo real, tamaño y nombre?
- ¿
display_errorsestá enOffen producción?
Errores frecuentes
- Imprimir con
echosin escapar. Es el origen del XSS. Usae()siempre. - Concatenar variables en la consulta SQL. Aunque “ya validé el dato”, usa una consulta preparada.
- Usar
md5()osha1()para contraseñas. Son demasiado rápidas; usapassword_hash. - Olvidar el token en un formulario o comprobarlo con
==en lugar dehash_equals(). - No regenerar el ID de sesión al iniciar sesión. Deja la puerta abierta a la fijación de sesión.
- Fiarse de
$_FILES['x']['type']o de la extensión. Los decide el usuario; detecta el tipo por el contenido. - Dejar
display_errors = Onen producción. Regala información a los atacantes. - Pensar que validar en el navegador (HTML o JavaScript) basta. Cualquiera puede saltárselo; valida siempre en el servidor.
Resumen
| Amenaza | Defensa | Herramienta |
|---|---|---|
| XSS | Escapar al imprimir | htmlspecialchars, urlencode |
| Inyección SQL | Consultas preparadas, lista permitida | prepare y execute |
| Contraseñas robadas | Hash lento con sal | password_hash, password_verify |
| CSRF | Token por sesión y POST | random_bytes, hash_equals |
| Robo y fijación de sesión | ID nuevo al entrar y cookie segura | session_regenerate_id, httponly, secure, samesite |
| Subidas peligrosas | Tipo real, nombre aleatorio, carpeta segura | finfo, move_uploaded_file |
| Errores que filtran datos | Mostrar solo en desarrollo | display_errors, log_errors |
En la siguiente lección verás cómo hablar con otros servicios y devolver datos con JSON y APIs.
Pon a prueba lo que has aprendido
¿Te ha quedado claro? Márcala y verás tu progreso en el explorador.