1. Recibir los datos de un formulario
Cuando un usuario envía un formulario, el navegador empaqueta los campos (con su atributo name) y los manda al servidor. PHP los deja preparados en unos arrays especiales llamados superglobales, accesibles desde cualquier parte del script: $_GET para los datos que viajan en la URL, $_POST para los del cuerpo de la petición, $_FILES para los ficheros, $_COOKIE para las cookies y $_SERVER para la información de la petición (método, ruta, IP…).
| GET | POST | |
|---|---|---|
| Dónde van los datos | En la URL: /buscar.php?q=teclado | En el cuerpo de la petición |
| Se ven, se guardan en el historial y se pueden compartir | Sí | No |
| Úsalo para | Buscar, filtrar, paginar: leer sin cambiar nada | Crear, modificar, borrar, iniciar sesión |
| Límite de tamaño | Unos pocos miles de caracteres | Grande (configurable) |
La regla práctica: si la petición cambia algo en el servidor, usa POST. Si solo consulta, GET, porque así el usuario puede guardar la búsqueda en marcadores o compartir el enlace.
2. Nunca te fíes de lo que llega
Todo lo que viene del cliente puede estar manipulado: el HTML puede tener un maxlength y un required, pero cualquiera puede enviar la petición a mano con los datos que quiera. Por eso el servidor valida siempre, aunque el formulario ya valide con JavaScript. Hay dos operaciones distintas que conviene no confundir:
Validar, sanear y escapar
1<?php
2declare(strict_types=1);
3
4$errores = [];
5$datos = ['nombre' => '', 'email' => '', 'edad' => ''];
6
7if ($_SERVER['REQUEST_METHOD'] === 'POST') {
8 $datos['nombre'] = trim($_POST['nombre'] ?? '');
9 $datos['email'] = trim($_POST['email'] ?? '');
10 $datos['edad'] = trim($_POST['edad'] ?? '');
11
12 if ($datos['nombre'] === '') {
13 $errores['nombre'] = 'El nombre es obligatorio';
14 }
15 if (!filter_var($datos['email'], FILTER_VALIDATE_EMAIL)) {
16 $errores['email'] = 'El email no es válido';
17 }
18 if (filter_var($datos['edad'], FILTER_VALIDATE_INT, ['options' => ['min_range' => 18]]) === false) {
19 $errores['edad'] = 'Debes ser mayor de edad';
20 }
21
22 if (!$errores) {
23 // … guardar en la base de datos …
24 header('Location: gracias.php'); // POST/redirección/GET
25 exit;
26 }
27}
28?>
29<form method="post" novalidate>
30 <input name="nombre" value="<?= htmlspecialchars($datos['nombre']) ?>">
31 <?php if (isset($errores['nombre'])): ?><span class="error"><?= $errores['nombre'] ?></span><?php endif ?>
32 <!-- … resto de campos … -->
33 <button>Registrarme</button>
34</form>El ejemplo muestra tres buenas costumbres. Los errores se guardan por campo para mostrarlos junto a cada uno. Si hay errores, el formulario se vuelve a pintar con los valores que el usuario escribió, para que no tenga que empezar de cero. Y si todo va bien, en lugar de mostrar una página directamente se hace una redirección (el patrón POST/redirección/GET): así, si el usuario recarga, no se vuelve a enviar el formulario ni se crea un registro duplicado.
XSS: el error más frecuente en PHP
echo $_GET['q'] directamente en la página permite que un atacante prepare un enlace con código JavaScript que se ejecutará en el navegador de quien lo abra. Todo dato variable que se imprime en HTML pasa por htmlspecialchars(). Los motores de plantillas como Twig o Blade lo hacen automáticamente.3. Subir ficheros
Para subir ficheros el formulario necesita method="post" y enctype="multipart/form-data". PHP guarda cada fichero en una carpeta temporal y describe en $_FILES su nombre original, tamaño, tipo y ruta temporal. Tu código decide si lo acepta y, en ese caso, lo mueve a su sitio con move_uploaded_file. Subir ficheros es una de las puertas de entrada favoritas de los atacantes, así que hay que ser estricto:
- Comprueba
$_FILES['foto']['error'] === UPLOAD_ERR_OKy un tamaño máximo. - No te fíes del tipo que envía el navegador: averigua el real con
finfo_filey acepta solo una lista cerrada (image/jpeg, image/png…). - No uses el nombre original: genera uno propio (
bin2hex(random_bytes(16)) . '.jpg'). - Guarda los ficheros fuera de la carpeta pública o en una carpeta sin permiso de ejecución de PHP.
4. Cookies y sesiones: recordar al usuario
HTTP no tiene memoria: cada petición llega sin saber nada de las anteriores. Para que la aplicación recuerde que has iniciado sesión o qué hay en tu carrito se usan dos mecanismos que trabajan juntos.
Una cookie es un pequeño dato que el servidor pide guardar al navegador (con la cabecera Set-Cookie) y que el navegador devuelve en cada petición siguiente. Vive en el ordenador del usuario, así que puede leerla y modificarla: nunca guardes en ella nada en lo que haya que confiar. Una sesión, en cambio, guarda los datos en el servidor, y en la cookie solo viaja un identificador largo y aleatorio que permite encontrarlos.
1<?php
2session_start([
3 'cookie_httponly' => true, // JavaScript no puede leer la cookie
4 'cookie_secure' => true, // solo viaja por HTTPS
5 'cookie_samesite' => 'Lax', // no se envía en peticiones de otras webs (protege de CSRF)
6]);
7
8$usuario = buscarUsuario($_POST['email'] ?? '');
9
10if ($usuario && password_verify($_POST['clave'] ?? '', $usuario['hash'])) {
11 session_regenerate_id(true); // identificador nuevo tras iniciar sesión
12 $_SESSION['usuario_id'] = $usuario['id'];
13 $_SESSION['rol'] = $usuario['rol'];
14 header('Location: panel.php');
15 exit;
16}
17$error = 'Email o contraseña incorrectos'; // no digas cuál de los dos falla| Cookie | Sesión | |
|---|---|---|
| Dónde se guardan los datos | En el navegador | En el servidor |
| Quién puede modificarlos | El usuario | Solo tu código |
| Duración | La que indiques (días, meses) | Hasta cerrar el navegador o caducar |
| Úsala para | Preferencias no críticas: idioma, tema | Identidad, permisos, carrito |
5. Autenticación y control de acceso
Autenticar es comprobar quién es el usuario; autorizar es decidir qué puede hacer. Las dos cosas se hacen en el servidor, en cada petición, y cualquier fallo aquí es una brecha de seguridad. Estas son las reglas que no se negocian:
Las contraseñas no se guardan, se cifran con un hash
Nunca guardes una contraseña tal cual ni la cifres de forma reversible. password_hash($clave, PASSWORD_DEFAULT) genera un hash con sal aleatoria usando un algoritmo lento a propósito (bcrypt o Argon2), y password_verify($clave, $hash) comprueba si coinciden. Si te roban la base de datos, descifrar esos hashes es muy costoso. MD5 y SHA1 no sirven para esto: son demasiado rápidos.
Protege cada página y cada acción
1function exigirRol(string $rol): void
2{
3 if (!isset($_SESSION['usuario_id'])) {
4 header('Location: login.php');
5 exit;
6 }
7 if ($_SESSION['rol'] !== $rol) {
8 http_response_code(403);
9 exit('No tienes permiso para ver esta página');
10 }
11}
12
13// Al principio de admin.php
14session_start();
15exigirRol('admin');Tokens contra CSRF
El ataque CSRF (falsificación de petición en sitios cruzados) consiste en que una web maliciosa haga que tu navegador envíe un formulario a otra web donde tienes la sesión abierta. La defensa clásica es un token aleatorio guardado en la sesión e incluido como campo oculto en cada formulario: si al recibir el POST no coinciden, se rechaza. Las cookies con SameSite=Lax añaden una segunda capa.
1$_SESSION['csrf'] ??= bin2hex(random_bytes(32));
2// En el formulario: <input type="hidden" name="csrf" value="<?= $_SESSION['csrf'] ?>">
3
4if (!hash_equals($_SESSION['csrf'], $_POST['csrf'] ?? '')) {
5 http_response_code(419);
6 exit('El formulario ha caducado. Vuelve a intentarlo.');
7}6. Retos
En estos retos la entrada estándar hace el papel de lo que llegaría del navegador: el cuerpo de un formulario, los comentarios de los usuarios o los intentos de inicio de sesión. El código se ejecuta con PHP 8.3 real.
parse_str convierte el cuerpo de una petición en un array, igual que PHP hace con $_POST.
Retos de PHP en el IDE
Escribe el código que falta y pulsa Ejecutar tests: se ejecuta con PHP 8.3 real y se prueba con varios casos, algunos ocultos.
La entrada es el cuerpo de un formulario enviado por POST (nombre=Ana&email=…&edad=…&web=…). Valida y escribe un error por línea, en este orden: «nombre: obligatorio» o «nombre: máximo 50 caracteres»; «email: no válido»; «edad: debe estar entre 18 y 120» (entero); «web: no válida» (es opcional, solo se valida si no está vacía). Si no hay errores, escribe «Datos correctos».
Cada línea es un comentario «autor|texto» que han escrito los usuarios. El código ya los muestra como <li>, pero es vulnerable: un usuario podría colar HTML o JavaScript. Corrígelo para que los caracteres especiales se escapen (< > & " ').
Las contraseñas se guardan cifradas con password_hash. Cada línea de la entrada es un intento «usuario contraseña» (la contraseña es lo que va después del primer espacio). Escribe «Bienvenido/a, usuario» si es correcto o «Usuario o contraseña incorrectos» si no (sin revelar si el usuario existe). Tras 3 fallos seguidos de un mismo usuario, su cuenta queda bloqueada: responde «Cuenta bloqueada» a cualquier intento posterior, aunque la contraseña sea correcta. Un acceso correcto reinicia sus fallos.