libro / blog / login-seguro-php

// capítulo 09 · autenticación

Sistema de login seguro en PHP y MySQL: password_hash y sesiones

Gustavo Arias10 min de lectura

TL;DR Guarda las contraseñas con password_hash() y compruébalas con password_verify(). Tras un login correcto, llama a session_regenerate_id(true), guarda solo el id del usuario en la sesión y protege las páginas privadas con una función que lo compruebe.

El login es casi siempre lo primero que se ataca de una aplicación, y también donde más errores se copian de tutoriales antiguos: contraseñas en MD5, sesiones que nunca se regeneran, mensajes que revelan qué emails existen. En el libro Programación Full-Stack el sistema de login es un capítulo completo y la base del proyecto de gestión de usuarios. Este artículo resume el enfoque.

La tabla de usuarios

CREATE TABLE usuarios (
    id            INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
    nombre        VARCHAR(80)  NOT NULL,
    email         VARCHAR(190) NOT NULL UNIQUE,
    password_hash VARCHAR(255) NOT NULL,
    creado_en     TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

La columna del hash es VARCHAR(255) a propósito: el algoritmo por defecto puede cambiar en futuras versiones de PHP y producir hashes más largos. El email es UNIQUE para que la base de datos impida duplicados aunque tu código falle.

Registro: nunca guardes la contraseña

Una contraseña no se guarda ni cifrada ni “encriptada con MD5”: se guarda su hash, una transformación de un solo sentido. password_hash() usa un algoritmo lento diseñado para esto (bcrypt por defecto) y añade una sal aleatoria automáticamente.

<?php
require_once __DIR__ . '/config/db.php';

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

$errores = [];
if ($nombre === '') $errores[] = 'El nombre es obligatorio.';
if (!filter_var($email, FILTER_VALIDATE_EMAIL)) $errores[] = 'Email no válido.';
if (mb_strlen($pass) < 10) $errores[] = 'La contraseña debe tener al menos 10 caracteres.';

if (!$errores) {
    $hash = password_hash($pass, PASSWORD_DEFAULT);

    try {
        $stmt = db()->prepare(
            'INSERT INTO usuarios (nombre, email, password_hash) VALUES (?, ?, ?)'
        );
        $stmt->execute([$nombre, $email, $hash]);
        header('Location: login.php?registrado=1');
        exit;
    } catch (PDOException $e) {
        if ((int) ($e->errorInfo[1] ?? 0) === 1062) { // clave duplicada
            $errores[] = 'Ya existe una cuenta con ese email.';
        } else {
            throw $e;
        }
    }
}
md5() y sha1() no sirven para contraseñas: son tan rápidos que un ordenador normal prueba millones de combinaciones por segundo. Si heredas un sistema con MD5, migra a password_hash la próxima vez que cada usuario inicie sesión.

Login: verificar y abrir la sesión

<?php
require_once __DIR__ . '/config/db.php';
require_once __DIR__ . '/config/sesion.php';

$error = '';

if ($_SERVER['REQUEST_METHOD'] === 'POST') {
    $stmt = db()->prepare('SELECT id, nombre, password_hash FROM usuarios WHERE email = ?');
    $stmt->execute([trim($_POST['email'] ?? '')]);
    $usuario = $stmt->fetch();

    if ($usuario && password_verify($_POST['password'] ?? '', $usuario['password_hash'])) {
        // 1. Nuevo id de sesión: evita la fijación de sesión
        session_regenerate_id(true);

        // 2. Guarda lo mínimo imprescindible
        $_SESSION['usuario_id'] = $usuario['id'];
        $_SESSION['nombre']     = $usuario['nombre'];

        // 3. Si el algoritmo por defecto cambió, actualiza el hash
        if (password_needs_rehash($usuario['password_hash'], PASSWORD_DEFAULT)) {
            $nuevo = password_hash($_POST['password'], PASSWORD_DEFAULT);
            db()->prepare('UPDATE usuarios SET password_hash = ? WHERE id = ?')
                ->execute([$nuevo, $usuario['id']]);
        }

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

    // El mismo mensaje para email inexistente y contraseña incorrecta
    $error = 'Email o contraseña incorrectos.';
}

El mensaje de error genérico no es pereza: si dijeras “ese email no existe”, cualquiera podría averiguar quién tiene cuenta en tu sitio.

Configura la cookie de sesión

<?php
session_set_cookie_params([
    'lifetime' => 0,                         // hasta cerrar el navegador
    'path'     => '/',
    'secure'   => !empty($_SERVER['HTTPS']), // solo por HTTPS en producción
    'httponly' => true,                      // JavaScript no puede leerla
    'samesite' => 'Lax',                     // mitiga ataques CSRF
]);
session_start();

Proteger páginas privadas

<?php
require_once __DIR__ . '/sesion.php';

function requiereLogin(): void
{
    if (empty($_SESSION['usuario_id'])) {
        header('Location: login.php');
        exit;
    }
}
<?php
require_once __DIR__ . '/config/auth.php';
requiereLogin(); // primera línea de cada página privada
?>
<h1>Hola, <?= htmlspecialchars($_SESSION['nombre']) ?></h1>
<a href="logout.php">Cerrar sesión</a>

Cerrar sesión correctamente

No basta con un unset: hay que vaciar los datos, borrar la cookie y destruir la sesión en el servidor.

<?php
require_once __DIR__ . '/config/sesion.php';

$_SESSION = [];

$p = session_get_cookie_params();
setcookie(session_name(), '', time() - 3600, $p['path'], $p['domain'], $p['secure'], $p['httponly']);

session_destroy();
header('Location: login.php');
exit;
Para un sistema en producción, haz que el cierre de sesión funcione con un formulario POST en lugar de un enlace, para que un sitio externo no pueda desconectar a tus usuarios.

Checklist de seguridad

Referencia: password_hash en el manual de PHP y la guía de autenticación de OWASP.

Construye un sistema de usuarios completo

El libro Programación Full-Stack dedica un capítulo al login y la autenticación, y un proyecto entero al sistema de gestión de usuarios.

Comprar en Amazon