PHP Sessions y Login: tu primer sistema de autenticación funcional

Tienes el CRUD funcionando, la base de datos conectada… y cualquiera puede entrar a cualquier página. Vamos a arreglarlo.

Si llegaste hasta aquí desde el post de PHP y MySQL CRUD, ya sabes guardar, leer, editar y borrar datos. El problema es que tu aplicación tiene las puertas abiertas de par en par: cualquiera que sepa la URL puede entrar al panel de administración, borrar registros o ver datos que no debería.

La solución es un sistema de login. Y para entender cómo funciona en PHP, antes hay que entender por qué existe el problema.

Por qué HTTP no recuerda quién eres

Cuando entras a una web y haces clic en un enlace, el navegador manda una petición al servidor. El servidor responde. Y ahí acaba todo: el servidor no tiene ni idea de que eres tú el que acaba de hacer esa petición, ni de que ya habías entrado antes.

HTTP funciona así. Cada petición es independiente. El servidor no tiene memoria entre una petición y la siguiente. Esto se llama ser stateless (sin estado), y es el motivo por el que necesitas sessions o cookies si quieres que la web te recuerde.

Una session es un mecanismo que PHP usa para guardar información del usuario en el servidor durante su visita. Una cookie es un pequeño archivo que se guarda en el navegador del usuario. Los dos resuelven el mismo problema de formas distintas, y en un sistema de login los usarás juntos.

session_start(): lo que hace por dentro

Cuando llamas a session_start(), PHP hace tres cosas: genera un ID único para esa sesión (algo como abc123def456...), crea un archivo en el servidor con los datos de esa sesión, y manda una cookie al navegador con ese ID.

A partir de ahí, cada vez que el navegador hace una petición incluye esa cookie con el ID. PHP lo lee, busca el archivo de sesión correspondiente y carga los datos. Es lo que hace que cuando navegas por Amazon con algo en el carrito, el carrito siga ahí en la siguiente página.

❌ Error que verás seguro:

Cannot modify header information - headers already sent. Pasa cuando llamas a session_start() después de haber escrito algo en pantalla (aunque sea un espacio en blanco antes del <?php). session_start() tiene que ser siempre lo primero, antes de cualquier HTML.

PHP — session_start() básico 📋
<?php
// ✅ BIEN: primera línea del archivo, antes de todo
session_start();

// Guardar un valor en la sesión
$_SESSION['usuario'] = 'Pedro';

// Leerlo en cualquier otro archivo (que también tenga session_start())
echo $_SESSION['usuario']; // Pedro

// ❌ MAL: esto da el error de headers
echo "Hola";
session_start(); // ya es tarde, ya se mandaron headers
?>

El formulario de login

Nada especial aquí: un formulario con email y contraseña que manda los datos con POST. El truco está en lo que haces con esos datos cuando llegan al servidor.

HTML — login.php 📋
<?php session_start(); ?>
<!DOCTYPE html>
<html lang="es">
<head>
    <meta charset="UTF-8">
    <title>Login</title>
</head>
<body>

<?php if (isset($_SESSION['error'])): ?>
    <p style="color: red;"><?php echo htmlspecialchars($_SESSION['error']); ?></p>
    <?php unset($_SESSION['error']); ?>
<?php endif; ?>

<form action="procesar-login.php" method="POST">
    <label>Email:</label>
    <input type="email" name="email" required>

    <label>Contraseña:</label>
    <input type="password" name="password" required>

    <button type="submit">Entrar</button>
</form>

</body>
</html>

El mensaje de error lo guardamos en sesión en lugar de pasarlo por URL. Si lo pasas por URL (?error=Contraseña incorrecta), queda expuesto en el historial del navegador y en los logs del servidor. La sesión es más limpia.

Verificar credenciales: el archivo que procesa el login

Aquí es donde la mayoría se atasca la primera vez. El error más común: guardar las contraseñas como texto plano en la base de datos y compararlas directamente. Si alguien accede a tu base de datos (un SQL injection, un backup mal configurado, lo que sea), tiene todas las contraseñas al descubierto.

PHP tiene password_hash() y password_verify() para esto. Los usas siempre, sin excepción.

PHP — procesar-login.php 📋
<?php
session_start();

// Solo aceptamos POST
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
    header('Location: login.php');
    exit;
}

$email    = trim($_POST['email'] ?? '');
$password = $_POST['password'] ?? '';

if (empty($email) || empty($password)) {
    $_SESSION['error'] = 'Rellena todos los campos';
    header('Location: login.php');
    exit;
}

// Conexión a la base de datos
$pdo = new PDO(
    'mysql:host=localhost;dbname=mi_app;charset=utf8',
    $_ENV['DB_USER'],
    $_ENV['DB_PASS']
);
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);

// Buscar usuario por email
$stmt = $pdo->prepare('SELECT id, nombre, password FROM usuarios WHERE email = ?');
$stmt->execute([$email]);
$usuario = $stmt->fetch(PDO::FETCH_ASSOC);

// Verificar que existe y que la contraseña es correcta
if (!$usuario || !password_verify($password, $usuario['password'])) {
    $_SESSION['error'] = 'Email o contraseña incorrectos';
    header('Location: login.php');
    exit;
}

// ✅ Login correcto: guardar datos en sesión
$_SESSION['usuario_id']     = $usuario['id'];
$_SESSION['usuario_nombre'] = $usuario['nombre'];

// Regenerar el ID de sesión para evitar session fixation
session_regenerate_id(true);

header('Location: panel.php');
exit;

❌ Error común con password_verify:

Usar password_verify($password, $password_guardado) pero haber guardado la contraseña sin hashear con password_hash(). Si la guardaste como texto plano, password_verify siempre devolverá false. Las contraseñas se hashean al crear el usuario, no al hacer login.

Crear usuarios con la contraseña hasheada

Para que el login funcione, los usuarios de tu base de datos tienen que tener la contraseña guardada con password_hash(). Si tienes un formulario de registro, es ahí donde se aplica:

PHP — registro.php (fragmento) 📋
<?php
// Al insertar un usuario nuevo, hashear la contraseña antes de guardar
$passwordHasheado = password_hash($_POST['password'], PASSWORD_DEFAULT);

$stmt = $pdo->prepare('INSERT INTO usuarios (nombre, email, password) VALUES (?, ?, ?)');
$stmt->execute([$nombre, $email, $passwordHasheado]);

PASSWORD_DEFAULT usa el algoritmo más seguro disponible en tu versión de PHP (actualmente bcrypt). No necesitas saber más sobre cómo funciona por dentro: PHP gestiona el salt automáticamente.

Proteger páginas con la sesión

Una vez que el usuario está logueado, necesitas proteger el resto de páginas para que solo las vean los usuarios autenticados. La forma más directa: un pequeño bloque al principio de cada página protegida.

PHP — panel.php 📋
<?php
session_start();

// Si no hay sesión activa, redirigir al login
if (!isset($_SESSION['usuario_id'])) {
    header('Location: login.php');
    exit;
}

// A partir de aquí, el usuario está autenticado
$nombre = htmlspecialchars($_SESSION['usuario_nombre']);
?>
<!DOCTYPE html>
<html lang="es">
<body>
    <h1>Bienvenido, <?php echo $nombre; ?></h1>
    <a href="logout.php">Cerrar sesión</a>
</body>
</html>

El exit después del header() es obligatorio. Sin él, PHP sigue ejecutando el resto del código aunque haya mandado la redirección. He visto casos donde sin el exit, el contenido protegido se renderiza igualmente durante una fracción de segundo.

💡 Mejor práctica:

Si tienes muchas páginas protegidas, crea un archivo auth.php con solo esas tres líneas y hazle require_once 'auth.php' al principio de cada página. Así cambias la lógica en un solo sitio si algún día la necesitas modificar.

Logout: cerrar la sesión correctamente

Cerrar sesión no es solo llamar a session_destroy(). Si solo destruyes la sesión del servidor pero no eliminas la cookie del navegador, la cookie sigue ahí aunque ya no apunte a ninguna sesión válida. No da problemas de seguridad graves, pero es una mala práctica.

PHP — logout.php 📋
<?php
session_start();

// Vaciar los datos de sesión
$_SESSION = [];

// Eliminar la cookie de sesión del navegador
if (ini_get('session.use_cookies')) {
    $params = session_get_cookie_params();
    setcookie(
        session_name(),
        '',
        time() - 42000,
        $params['path'],
        $params['domain'],
        $params['secure'],
        $params['httponly']
    );
}

// Destruir la sesión en el servidor
session_destroy();

header('Location: login.php');
exit;

Cookies: cuándo y cómo usarlas

Las sessions se borran cuando el usuario cierra el navegador (por defecto). Si quieres implementar un "Recuérdame" que mantenga al usuario logueado durante días, necesitas cookies.

La diferencia clave: las cookies se guardan en el navegador del usuario y tienen fecha de expiración. Las sesiones se guardan en el servidor y desaparecen al cerrar el navegador (o después del tiempo que configure PHP, generalmente 24 minutos de inactividad).

PHP — setcookie() básico 📋
<?php
// Guardar una cookie que dura 30 días
setcookie(
    'recordar_usuario',       // nombre
    $usuario['id'],           // valor
    time() + (30 * 24 * 3600), // expiración: 30 días
    '/',                      // path: toda la web
    '',                       // dominio (vacío = dominio actual)
    true,                     // secure: solo HTTPS
    true                      // httponly: no accesible desde JavaScript
);

// Leer la cookie en otra página
if (isset($_COOKIE['recordar_usuario'])) {
    $userId = (int) $_COOKIE['recordar_usuario'];
    // Buscar el usuario en la BD y loguearlo
}

// Eliminar la cookie (expiración en el pasado)
setcookie('recordar_usuario', '', time() - 3600, '/');

❌ Nunca guardes la contraseña en una cookie:

Ni hasheada, ni en texto plano. Las cookies son accesibles desde el navegador y pueden ser robadas con XSS. Guarda solo el ID de usuario (o mejor, un token aleatorio vinculado al usuario en la base de datos) y siempre con httponly: true.

La tabla de usuarios en MySQL

Para que todo lo anterior funcione, necesitas una tabla con la estructura mínima. Esta es la que usamos en los ejemplos:

SQL — crear tabla usuarios 📋
CREATE TABLE usuarios (
    id       INT AUTO_INCREMENT PRIMARY KEY,
    nombre   VARCHAR(100) NOT NULL,
    email    VARCHAR(150) NOT NULL UNIQUE,
    password VARCHAR(255) NOT NULL,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

-- Insertar un usuario de prueba con contraseña hasheada
-- La contraseña "miPassword123" hasheada con password_hash():
INSERT INTO usuarios (nombre, email, password) VALUES (
    'Pedro',
    'pedro@ejemplo.com',
    '$2y$10$ejemplo_de_hash_generado_por_password_hash'
);

-- En la práctica, genera el hash desde PHP:
-- echo password_hash('miPassword123', PASSWORD_DEFAULT);

El campo password tiene que ser VARCHAR(255) mínimo. Los hashes de bcrypt tienen 60 caracteres ahora mismo, pero PHP puede cambiar el algoritmo por defecto en el futuro y el hash podría ser más largo. 255 te da margen.

session_regenerate_id(): por qué está en el código

Hay un ataque llamado session fixation donde alguien te manda un enlace con un ID de sesión predefinido (?PHPSESSID=abc123), y si haces login con esa sesión, el atacante ya conoce tu ID y puede hacerse pasar por ti.

Llamar a session_regenerate_id(true) justo después del login genera un nuevo ID de sesión, invalidando el anterior. El true como parámetro borra también el archivo de sesión antiguo del servidor. Esto me costó pillarlo: sin el parámetro true, el archivo viejo sigue existiendo un tiempo.

PHP — dónde va session_regenerate_id() 📋
<?php
// Solo después de verificar que el login es correcto
if (!$usuario || !password_verify($password, $usuario['password'])) {
    // login incorrecto, no regenerar
    $_SESSION['error'] = 'Credenciales incorrectas';
    header('Location: login.php');
    exit;
}

// ✅ Login correcto: primero guardar datos, luego regenerar
$_SESSION['usuario_id'] = $usuario['id'];
session_regenerate_id(true); // ahora sí

header('Location: panel.php');
exit;

Estructura completa de archivos

Para que tengas claro cómo encaja todo junto:

Estructura del proyecto 📋
mi-app/
├── login.php          → formulario de login
├── procesar-login.php → verifica credenciales y crea sesión
├── registro.php       → formulario de registro (hashea contraseña)
├── logout.php         → destruye sesión y cookie
├── auth.php           → verificación de sesión (require en páginas protegidas)
└── panel.php          → página protegida (require_once 'auth.php' al inicio)

💡 auth.php — el archivo que reutilizas en todo:

Crea un archivo auth.php con solo session_start() y la comprobación de $_SESSION['usuario_id']. Ponlo con require_once 'auth.php' en cada página protegida. Si algún día cambias la lógica de autenticación, lo cambias en un solo sitio.

Preguntas frecuentes

¿Puedo usar sessions sin base de datos?
Sí. Si solo quieres proteger páginas con una contraseña única (tipo panel de admin simple), puedes hardcodear la contraseña hasheada en el PHP. No es para aplicaciones con múltiples usuarios, pero funciona perfectamente para un admin personal.

¿Cuánto dura una sesión?
Por defecto en PHP, la sesión expira después de 1440 segundos (24 minutos) de inactividad. Puedes cambiarlo con ini_set('session.gc_maxlifetime', 7200) para 2 horas. En XAMPP local esto funciona sin problemas; en producción depende de la configuración del servidor.

¿Y si quiero usar JWT en vez de sessions?
Los JWT son la alternativa moderna, especialmente para APIs y aplicaciones React/Vue donde el backend y el frontend están separados. Para una aplicación PHP tradicional donde todo está en el mismo servidor, las sessions son más sencillas y perfectamente válidas.

El siguiente paso lógico

Tienes un sistema de login funcionando. El siguiente nivel es separar bien los roles: que un usuario normal no pueda acceder a las páginas de administración aunque esté logueado.

👉 PHP y MySQL CRUD: Tu primera base de datos conectada

Si aún no tienes el CRUD montado, empieza por ahí. El login sin base de datos solo sirve para demo.