Sistema de autenticación seguro en PHP: registro, login y protección CSRF
En este tutorial construirás un sistema de autenticación básico pero seguro con PHP puro: registro, login, logout, protección CSRF, almacenamiento seguro de contraseñas y buenas prácticas de sesión. Código completo, estructura de carpetas y explicación de por qué se hacen las cosas así.
Requisitos previos
- PHP 8.0+ (idealmente 8.1/8.2)
- Servidor web (Apache/Nginx) o PHP built-in server para pruebas
- MySQL / MariaDB
- Acceso para crear una base de datos
- Conocimientos básicos de PHP, PDO y HTML
Estructura de carpetas
auth-php/
├─ public/
│ ├─ index.php # Página pública (redirección a login)
│ ├─ register.php
│ ├─ login.php
│ ├─ dashboard.php
│ └─ logout.php
├─ src/
│ ├─ init.php # Inicia sesión y BD
│ └─ functions.php # Helpers: csrf, auth, util
├─ config.php # Configuración (BD, opciones)
├─ migrations.sql # Tabla users
└─ .htaccess # (opcional) ajustes de seguridad para Apache
SQL: creación de la tabla users
-- migrations.sql
CREATE TABLE users (
id INT AUTO_INCREMENT PRIMARY KEY,
email VARCHAR(255) NOT NULL UNIQUE,
password VARCHAR(255) NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
Archivo: config.php
<?php
// config.php
return [
'db' => [
'host' => '127.0.0.1',
'name' => 'auth_db',
'user' => 'dbuser',
'pass' => 'dbpass',
'charset' => 'utf8mb4',
],
// Opciones adicionales
'session_cookie_secure' => true, // poner true en producción con HTTPS
];
Archivo: src/init.php
<?php
// src/init.php
// Inicia sesión y crea la conexión PDO
if (session_status() === PHP_SESSION_NONE) {
// Configuraciones de seguridad de la cookie
$cfg = require __DIR__ . '/../config.php';
session_set_cookie_params([
'httponly' => true,
'secure' => $cfg['session_cookie_secure'] ?? false,
'samesite' => 'Lax',
]);
session_start();
}
// Conexión PDO
$config = require __DIR__ . '/../config.php';
$dsn = sprintf('mysql:host=%s;dbname=%s;charset=%s', $config['db']['host'], $config['db']['name'], $config['db']['charset']);
try {
$pdo = new PDO($dsn, $config['db']['user'], $config['db']['pass'], [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
PDO::ATTR_EMULATE_PREPARES => false,
]);
} catch (PDOException $e) {
// En producción no devuelvas el mensaje completo
error_log('DB connection failed: ' . $e->getMessage());
http_response_code(500);
echo 'Error de conexión a la base de datos.';
exit;
}
// Exponer $pdo
$GLOBALS['pdo'] = $pdo;
Archivo: src/functions.php
<?php
// src/functions.php
require_once __DIR__ . '/init.php';
function csrf_token(): string
{
if (empty($_SESSION['csrf_token'])) {
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));
}
return $_SESSION['csrf_token'];
}
function verify_csrf_token(?string $token): bool
{
if (empty($token) || empty($_SESSION['csrf_token'])) return false;
return hash_equals($_SESSION['csrf_token'], $token);
}
function sanitize(string $s): string
{
return htmlspecialchars(trim($s), ENT_QUOTES, 'UTF-8');
}
function get_user_by_email(string $email)
{
$pdo = $GLOBALS['pdo'];
$stmt = $pdo->prepare('SELECT id, email, password FROM users WHERE email = :email');
$stmt->execute(['email' => $email]);
return $stmt->fetch();
}
function create_user(string $email, string $password): bool
{
$pdo = $GLOBALS['pdo'];
$hash_algo = defined('PASSWORD_ARGON2ID') ? PASSWORD_ARGON2ID : PASSWORD_BCRYPT;
$hash = password_hash($password, $hash_algo);
$stmt = $pdo->prepare('INSERT INTO users (email, password) VALUES (:email, :password)');
try {
return $stmt->execute(['email' => $email, 'password' => $hash]);
} catch (PDOException $e) {
// duplicate email etc.
return false;
}
}
function login_user(int $user_id)
{
// Regenera id de sesión para evitar fixation
session_regenerate_id(true);
$_SESSION['user_id'] = $user_id;
// Opcional: almacenar fingerprint (user agent + ip parcial)
$_SESSION['fingerprint'] = hash('sha256', ($_SERVER['HTTP_USER_AGENT'] ?? '') . (getenv('REMOTE_ADDR') ?? ''));
}
function is_authenticated(): bool
{
if (empty($_SESSION['user_id'])) return false;
// Verificar fingerprint si existe
if (!empty($_SESSION['fingerprint'])) {
$fp = hash('sha256', ($_SERVER['HTTP_USER_AGENT'] ?? '') . (getenv('REMOTE_ADDR') ?? ''));
return hash_equals($_SESSION['fingerprint'], $_SESSION['fingerprint']);
}
return true;
}
function require_login()
{
if (empty($_SESSION['user_id'])) {
header('Location: login.php');
exit;
}
}
function logout()
{
// Borra session y cookies
$_SESSION = [];
if (ini_get('session.use_cookies')) {
$params = session_get_cookie_params();
setcookie(session_name(), '', time() - 42000,
$params['path'], $params['domain'], $params['secure'], $params['httponly']
);
}
session_destroy();
}
Archivo: public/register.php
<?php
require_once __DIR__ . '/../src/functions.php';
$errors = [];
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
if (!verify_csrf_token($_POST['csrf'] ?? null)) {
$errors[] = 'Token CSRF inválido.';
}
$email = filter_var($_POST['email'] ?? '', FILTER_VALIDATE_EMAIL);
$password = $_POST['password'] ?? '';
$password2 = $_POST['password2'] ?? '';
if (!$email) $errors[] = 'Email inválido.';
if (strlen($password) < 8) $errors[] = 'La contraseña debe tener al menos 8 caracteres.';
if ($password !== $password2) $errors[] = 'Las contraseñas no coinciden.';
if (empty($errors)) {
if (get_user_by_email($email)) {
$errors[] = 'Ya existe un usuario con ese email.';
} else {
$ok = create_user($email, $password);
if ($ok) {
header('Location: login.php?registered=1');
exit;
} else {
$errors[] = 'Error al crear usuario.';
}
}
}
}
$token = csrf_token();
?>
<!doctype html>
<html lang="es">
<head>
<meta charset="utf-8">
<title>Registro</title>
</head>
<body>
<h2>Registro</h2>
<?php if ($errors): ?>
<ul style="color:red">
<?php foreach ($errors as $e) echo "<li>".htmlspecialchars($e)."</li>"; ?>
</ul>
<?php endif; ?>
<form method="post" action="">
<input type="hidden" name="csrf" value="<?= htmlspecialchars($token) ?>"/>
<label>Email: <input type="email" name="email" required /></label><br/>
<label>Contraseña: <input type="password" name="password" required /></label><br/>
<label>Repite contraseña: <input type="password" name="password2" required /></label><br/>
<button type="submit">Registrarse</button>
</form>
<p><a href="login.php">Ir a login</a></p>
</body>
</html>
Archivo: public/login.php
<?php
require_once __DIR__ . '/../src/functions.php';
$errors = [];
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
if (!verify_csrf_token($_POST['csrf'] ?? null)) {
$errors[] = 'Token CSRF inválido.';
}
$email = filter_var($_POST['email'] ?? '', FILTER_VALIDATE_EMAIL);
$password = $_POST['password'] ?? '';
if (!$email || $password === '') {
$errors[] = 'Email y contraseña son obligatorios.';
}
if (empty($errors)) {
$user = get_user_by_email($email);
if (!$user || !password_verify($password, $user['password'])) {
$errors[] = 'Credenciales inválidas.';
} else {
login_user((int)$user['id']);
header('Location: dashboard.php');
exit;
}
}
}
$token = csrf_token();
?>
<!doctype html>
<html lang="es">
<head>
<meta charset="utf-8">
<title>Login</title>
</head>
<body>
<h2>Login</h2>
<?php if (!empty($_GET['registered'])) echo '<p style="color:green">Registro completado, ya puedes iniciar sesión.</p>'; ?>
<?php if ($errors): ?>
<ul style="color:red">
<?php foreach ($errors as $e) echo "<li>".htmlspecialchars($e)."</li>"; ?>
</ul>
<?php endif; ?>
<form method="post" action="">
<input type="hidden" name="csrf" value="<?= htmlspecialchars($token) ?>"/>
<label>Email: <input type="email" name="email" required /></label><br/>
<label>Contraseña: <input type="password" name="password" required /></label><br/>
<button type="submit">Entrar</button>
</form>
<p><a href="register.php">Registrarse</a></p>
</body>
</html>
Archivo: public/dashboard.php
<?php
require_once __DIR__ . '/../src/functions.php';
require_login();
// Obtener datos usuario si deseas
$pdo = $GLOBALS['pdo'];
$stmt = $pdo->prepare('SELECT email, created_at FROM users WHERE id = :id');
$stmt->execute(['id' => $_SESSION['user_id']]);
$user = $stmt->fetch();
?>
<!doctype html>
<html lang="es">
<head>
<meta charset="utf-8">
<title>Dashboard</title>
</head>
<body>
<h2>Dashboard</h2>
<p>Has iniciado sesión como <?= htmlspecialchars($user['email'] ?? 'usuario') ?></p>
<p>Miembro desde: <?= htmlspecialchars($user['created_at'] ?? '') ?></p>
<p><a href="logout.php">Cerrar sesión</a></p>
</body>
</html>
Archivo: public/logout.php
<?php
require_once __DIR__ . '/../src/functions.php';
logout();
header('Location: login.php');
exit;
Archivo: public/index.php
<?php
header('Location: login.php');
exit;
.htaccess (opcional para Apache)
# .htaccess
# Forzar headers de seguridad básicos (ajusta según tu infraestructura)
<ifModule mod_headers.c>
Header always set X-Content-Type-Options: "nosniff"
Header always set X-Frame-Options: "SAMEORIGIN"
Header always set X-XSS-Protection: "1; mode=block"
</ifModule>
Por qué estas decisiones (no solo cómo)
- PDO con prepared statements: evita SQL injection y es portable.
- password_hash/password_verify: almacena hashes con sal automática y algoritmos robustos (Argon2 si está disponible).
- session_regenerate_id(true) al iniciar sesión: evita session fixation (un atacante reusa una sesión conocida).
- CSRF token por formulario: protege acciones que cambian estado (registro, login). Aunque el login CSRF es menos crítico, incluirlo es buena práctica.
- session cookie flags (httponly, secure, samesite): reducen riesgos de XSS y CSRF en cookies.
- Validación del lado servidor (email, longitud de contraseña): evita datos incorrectos o intentos de abuso.
- No mostrar errores de BD al usuario: evita leak de información sensible.
Pruebas rápidas
- Importa migrations.sql en tu BD y configura config.php.
- Coloca la carpeta public/ como root web (DocumentRoot) o usa php -S localhost:8000 -t public
- Accede a /register.php, crea usuario y prueba login/logout.
Limitaciones y siguientes pasos
- Este ejemplo no implementa rate-limiting ni bloqueo de cuenta tras intentos fallidos: agrega throttling (Redis o tablas) en producción.
- Considera 2FA (TOTP) para mayor seguridad.
- Usa HTTPS en todo el sitio y HSTS en servidor.
- Para aplicaciones grandes, separa lógica en capas (MVC), usa un framework y librerías maduras para autenticación y CSRF.
Advertencia de seguridad:
En producción, asegúrate de servir siempre por HTTPS, habilitar las banderas de cookie apropiadas, limitar intentos de login y almacenar las credenciales de BD fuera del repositorio (variables de entorno o un gestor de secretos).
Si quieres, puedo mostrar cómo añadir bloqueo por intentos fallidos, integración con Redis para sesiones y rate-limiting, o migrar este ejemplo a un micro-framework como Slim o Symfony para escalabilidad y pruebas automatizadas.
¿Quieres comentar?
Inicia sesión con Telegram para participar en la conversación