Después de implementar login, sesiones, roles y permisos, el siguiente paso es aprender a proteger correctamente una aplicación PHP.
La seguridad no consiste en una sola técnica. Una aplicación puede utilizar contraseñas seguras y, al mismo tiempo, ser vulnerable a inyección SQL, XSS o CSRF.
En esta guía vamos a repasar algunas de las vulnerabilidades más comunes y cómo prevenirlas.
1. SQL Injection
Una inyección SQL ocurre cuando datos ingresados por el usuario terminan formando parte directa de una consulta SQL.
Este código es inseguro:
<?php $email = $_POST['email']; $sql = "SELECT * FROM usuarios WHERE email = '$email'"; $usuario = $pdo->query($sql)->fetch(); ?>
El problema es que el contenido de $email se concatena directamente dentro de la consulta.
La solución: consultas preparadas
Con PDO debemos separar los datos de la consulta:
<?php $sql = 'SELECT * FROM usuarios WHERE email = :email'; $stmt = $pdo->prepare($sql); $stmt->execute([ 'email' => $email ]); $usuario = $stmt->fetch(PDO::FETCH_ASSOC); ?>
El marcador :email evita que el valor recibido sea interpretado como parte de la consulta SQL.
No confiar en addslashes()
Escapar manualmente textos no reemplaza a las consultas preparadas.
La opción recomendada es utilizar prepare() y execute().
2. XSS: Cross-Site Scripting
XSS aparece cuando mostramos contenido enviado por un usuario sin escapar correctamente el HTML.
Por ejemplo:
echo $_POST['nombre'];
Si el usuario envía código HTML o JavaScript, ese contenido podría ejecutarse en el navegador.
Escapar la salida con htmlspecialchars()
echo htmlspecialchars(
$_POST['nombre'],
ENT_QUOTES,
'UTF-8'
);
Esto convierte caracteres especiales en entidades HTML y evita que el navegador los interprete como etiquetas.
Escapar al mostrar, no necesariamente al guardar
Una regla práctica importante es guardar los datos de forma adecuada y aplicar el escape según el contexto de salida.
Por ejemplo:
<h1>
<?= htmlspecialchars($usuario['nombre'], ENT_QUOTES, 'UTF-8') ?>
</h1>
3. Validación y sanitización
Validar significa comprobar que un dato cumple las reglas esperadas.
Sanitizar significa limpiar o transformar un valor.
Validar un email
if (!filter_var($email, FILTER_VALIDATE_EMAIL)) { $errores[] = 'El email no es válido'; }
Validar un número entero
$edad = filter_input(INPUT_POST, 'edad', FILTER_VALIDATE_INT); if ($edad === false) { $errores[] = 'La edad debe ser un número entero'; }
No confiar solamente en validaciones HTML
Este campo:
<input type="email" name="email" required>
mejora la experiencia del usuario, pero no reemplaza la validación en PHP.
Las peticiones pueden enviarse sin utilizar el formulario del navegador.
4. CSRF: Cross-Site Request Forgery
Un ataque CSRF intenta lograr que un usuario autenticado ejecute una acción sin darse cuenta.
Por ejemplo, una petición podría intentar eliminar un usuario utilizando la sesión activa de un administrador.
Crear un token CSRF
Podemos generar un valor aleatorio y guardarlo en la sesión:
<?php session_start(); if (empty($_SESSION['csrf_token'])) { $_SESSION['csrf_token'] = bin2hex(random_bytes(32)); } ?>
Agregar el token al formulario
<input
type="hidden"
name="csrf_token"
value="<?= htmlspecialchars($_SESSION['csrf_token']) ?>"
>
Validar el token
if ( !isset($_POST['csrf_token']) || !hash_equals( $_SESSION['csrf_token'], $_POST['csrf_token'] ) ) { http_response_code(403); exit('Token CSRF inválido'); }
Las operaciones importantes deberían usar POST
No conviene eliminar datos utilizando enlaces como:
<a href="eliminar.php?id=10">Eliminar</a>
Para operaciones que modifican información es preferible utilizar formularios con método POST y protección CSRF.
5. Contraseñas seguras
Las contraseñas nunca deben almacenarse en texto plano.
Para guardarlas:
$hash = password_hash(
$password,
PASSWORD_DEFAULT
);
Para comprobarlas:
if (password_verify($password, $hash)) { // Contraseña correcta }
No utilizar MD5 o SHA1 para contraseñas
Aunque son funciones hash, md5() y sha1() no están diseñadas para almacenar contraseñas de forma segura.
PHP ofrece específicamente:
password_hash();password_verify();password_needs_rehash().
6. Seguridad de las sesiones
Después de un login correcto conviene regenerar el identificador:
session_regenerate_id(true);
También podemos configurar las cookies de sesión:
session_set_cookie_params([
'httponly' => true,
'secure' => true,
'samesite' => 'Lax'
]);
session_start();
secure debe utilizarse cuando el sitio funciona mediante HTTPS.
7. No mostrar errores sensibles en producción
Durante el desarrollo puede ser útil visualizar errores, pero en producción pueden revelar información interna.
Por ejemplo:
- rutas del servidor;
- nombres de archivos;
- estructura de la base de datos;
- credenciales mal configuradas;
- detalles del código.
En producción conviene registrar errores en logs y mostrar al usuario mensajes genéricos.
8. Manejo seguro de archivos subidos
Las subidas de archivos requieren controles adicionales.
No debemos confiar solamente en:
$_FILES['archivo']['type']
Ese valor proviene del cliente.
Podemos analizar el tipo MIME real con finfo:
$finfo = new finfo(FILEINFO_MIME_TYPE);
$tipo = $finfo->file(
$_FILES['archivo']['tmp_name']
);
Después podemos compararlo contra una lista permitida.
$permitidos = [
'image/jpeg',
'image/png'
];
if (!in_array($tipo, $permitidos, true)) {
exit('Tipo de archivo no permitido');
}
No utilizar directamente el nombre original
En vez de confiar en el nombre recibido, podemos generar uno aleatorio:
$nombreSeguro = bin2hex(random_bytes(16)) . '.jpg';
9. Validar identificadores
Un identificador recibido mediante GET también debe validarse.
$id = filter_input(INPUT_GET, 'id', FILTER_VALIDATE_INT); if (!$id) { http_response_code(400); exit('ID inválido'); }
10. Autorización en cada operación
Aunque un usuario conozca una URL, no debería poder ejecutar una operación sin permiso.
Por ejemplo:
if (!tienePermiso( $pdo, $_SESSION['usuario_id'], 'usuarios.eliminar' )) { http_response_code(403); exit('Acceso denegado'); }
11. Variables de entorno y credenciales
No conviene publicar credenciales directamente en archivos accesibles o repositorios públicos.
Información como:
- usuario de MySQL;
- contraseña de la base;
- tokens de APIs;
- claves privadas;
debe tratarse como información sensible.
12. Aplicar el principio de mínimo privilegio
Un usuario de base de datos utilizado por una aplicación no necesariamente necesita permisos administrativos completos.
La idea es conceder solamente los privilegios necesarios para que la aplicación funcione.
Ejemplo de formulario más seguro
<?php session_start(); if (empty($_SESSION['csrf_token'])) { $_SESSION['csrf_token'] = bin2hex(random_bytes(32)); } ?> <form method="POST"> <input type="hidden" name="csrf_token" value="<?= htmlspecialchars($_SESSION['csrf_token']) ?>" > <label>Email</label> <input type="email" name="email" required > <button type="submit"> Guardar </button> </form>
Procesar el formulario
<?php if ($_SERVER['REQUEST_METHOD'] === 'POST') { if ( !isset($_POST['csrf_token']) || !hash_equals( $_SESSION['csrf_token'], $_POST['csrf_token'] ) ) { http_response_code(403); exit('Solicitud inválida'); } $email = filter_input( INPUT_POST, 'email', FILTER_VALIDATE_EMAIL ); if (!$email) { exit('Email inválido'); } $stmt = $pdo->prepare( 'UPDATE usuarios SET email = :email WHERE id = :id' ); $stmt->execute([ 'email' => $email, 'id' => $_SESSION['usuario_id'] ]); } ?>
Checklist de seguridad para una aplicación PHP
- usar PDO con consultas preparadas;
- validar todos los datos externos;
- escapar correctamente el contenido al mostrarlo;
- proteger formularios sensibles contra CSRF;
- usar
password_hash()ypassword_verify(); - regenerar la sesión después del login;
- verificar roles y permisos en el servidor;
- no confiar en cookies ni campos ocultos;
- validar correctamente archivos subidos;
- no mostrar errores internos en producción;
- utilizar HTTPS;
- mantener PHP y las dependencias actualizadas.
Errores comunes
- creer que la validación de JavaScript es suficiente;
- concatenar valores dentro de consultas SQL;
- mostrar contenido de usuarios sin escapar;
- usar GET para acciones destructivas;
- guardar contraseñas con MD5 o SHA1;
- confiar en el nombre o tipo MIME enviado por un archivo;
- ocultar botones sin proteger la operación real;
- guardar secretos directamente en repositorios públicos.
Ejercicio práctico
Tomá el sistema de usuarios de las guías anteriores y agregale:
- consultas preparadas en todas las operaciones;
- escape de nombres y datos mostrados en HTML;
- validación del email;
- token CSRF para editar y eliminar usuarios;
- protección por permisos;
- regeneración de sesión al iniciar sesión;
- mensajes de error seguros.
¿Qué sigue?
En la próxima guía podemos avanzar con programación orientada a objetos en PHP, un paso fundamental antes de comenzar a trabajar con arquitecturas más organizadas y frameworks como Laravel.
Veremos clases, objetos, propiedades, métodos, constructores, encapsulamiento, herencia e interfaces.
¿Te sirvió esta guía? ☕
Si este contenido te ayudó y querés apoyar a Club Programador para seguir publicando ejercicios, proyectos y guías gratuitas, podés colaborar mediante:
Descubre más desde Club Programador
Suscríbete y recibe las últimas entradas en tu correo electrónico.