Sistema de autenticación seguro en PHP (registro, login, CSRF, sesiones)
Este tutorial te guía paso a paso para crear un sistema de autenticación básico y seguro en PHP usando PDO, password_hash, tokens CSRF y buenas prácticas de sesión. Incluye código completo y explicaciones de por qué se toman estas decisiones.
Requisitos previos
- PHP 7.4+ (idealmente 8.0+)
- Servidor web (Apache/Nginx) y MySQL/MariaDB
- Conocimientos básicos de PHP y SQL
Estructura de carpetas
project/
public/
index.php
register.php
dashboard.php
logout.php
src/
config.php
db.php
functions.php
sql/
users.sql
SQL: tabla de usuarios
-- sql/users.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: src/config.php
<?php
// src/config.php
// Parámetros mínimos; en producción usa variables de entorno
return [
'db_host' => '127.0.0.1',
'db_name' => 'testdb',
'db_user' => 'dbuser',
'db_pass' => 'dbpass',
'cookie_secure' => true, // true si usas HTTPS
'cookie_samesite' => 'Lax' // 'Strict' o 'Lax'
];
Archivo: src/db.php
<?php
// src/db.php
$config = require __DIR__ . '/config.php';
$dsn = 'mysql:host=' . $config['db_host'] . ';dbname=' . $config['db_name'] . ';charset=utf8mb4';
$options = [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
PDO::ATTR_EMULATE_PREPARES => false,
];
try {
$pdo = new PDO($dsn, $config['db_user'], $config['db_pass'], $options);
} catch (PDOException $e) {
// En producción loguea esto y muestra un mensaje genérico
die('DB connection failed: ' . $e->getMessage());
}
return $pdo;
Archivo: src/functions.php
<?php
// src/functions.php
function start_secure_session(array $config)
{
$cookieParams = session_get_cookie_params();
session_set_cookie_params([
'lifetime' => $cookieParams['lifetime'],
'path' => $cookieParams['path'],
'domain' => $cookieParams['domain'],
'secure' => $config['cookie_secure'],
'httponly' => true,
'samesite' => $config['cookie_samesite'],
]);
if (session_status() === PHP_SESSION_NONE) {
session_start();
}
}
function generate_csrf_token()
{
if (empty($_SESSION['csrf_token'])) {
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));
}
return $_SESSION['csrf_token'];
}
function verify_csrf_token($token)
{
return isset($_SESSION['csrf_token']) && hash_equals($_SESSION['csrf_token'], $token);
}
function is_logged_in()
{
return !empty($_SESSION['user_id']);
}
function require_login()
{
if (!is_logged_in()) {
header('Location: index.php');
exit;
}
}
Archivo: public/register.php (registro)
<?php
// public/register.php
require __DIR__ . '/../src/db.php';
$config = require __DIR__ . '/../src/config.php';
require __DIR__ . '/../src/functions.php';
start_secure_session($config);
$errors = [];
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
if (!verify_csrf_token($_POST['csrf'] ?? '')) {
$errors[] = 'Token CSRF inválido.';
}
$email = filter_var(trim($_POST['email'] ?? ''), FILTER_VALIDATE_EMAIL);
$password = $_POST['password'] ?? '';
if (!$email) {
$errors[] = 'Email inválido.';
}
if (strlen($password) < 8) {
$errors[] = 'La contraseña debe tener al menos 8 caracteres.';
}
if (empty($errors)) {
// Comprobar usuario existente
$stmt = $pdo->prepare('SELECT id FROM users WHERE email = ?');
$stmt->execute([$email]);
if ($stmt->fetch()) {
$errors[] = 'El email ya está registrado.';
} else {
$hash = password_hash($password, PASSWORD_DEFAULT);
$stmt = $pdo->prepare('INSERT INTO users (email, password) VALUES (?, ?)');
$stmt->execute([$email, $hash]);
header('Location: index.php?registered=1');
exit;
}
}
}
$csrf = generate_csrf_token();
?>
<!doctype html>
<html lang='es'>
<head>
<meta charset='utf-8'>
<title>Registro</title>
</head>
<body>
<h1>Registro</h1>
<?php if ($errors): ?>
<ul><?php foreach ($errors as $e) echo '<li>' . htmlspecialchars($e, ENT_QUOTES, 'UTF-8') . '</li>'; ?></ul>
<?php endif; ?>
<form method='post' action='register.php'>
<label>Email:<input type='email' name='email' required></label><br>
<label>Contraseña:<input type='password' name='password' required></label><br>
<input type='hidden' name='csrf' value='<?php echo htmlspecialchars($csrf); ?>'>
<button type='submit'>Registrar</button>
</form>
<p><a href='index.php'>Ir al login</a></p>
</body>
</html>
Archivo: public/index.php (login)
<?php
// public/index.php
require __DIR__ . '/../src/db.php';
$config = require __DIR__ . '/../src/config.php';
require __DIR__ . '/../src/functions.php';
start_secure_session($config);
$errors = [];
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
if (!verify_csrf_token($_POST['csrf'] ?? '')) {
$errors[] = 'Token CSRF inválido.';
}
$email = filter_var(trim($_POST['email'] ?? ''), FILTER_VALIDATE_EMAIL);
$password = $_POST['password'] ?? '';
if (!$email) {
$errors[] = 'Email inválido.';
}
if (empty($errors)) {
$stmt = $pdo->prepare('SELECT id, password FROM users WHERE email = ?');
$stmt->execute([$email]);
$user = $stmt->fetch();
if ($user && password_verify($password, $user['password'])) {
// Login exitoso
session_regenerate_id(true);
$_SESSION['user_id'] = $user['id'];
header('Location: dashboard.php');
exit;
} else {
$errors[] = 'Credenciales incorrectas.';
}
}
}
$csrf = generate_csrf_token();
?>
<!doctype html>
<html lang='es'>
<head>
<meta charset='utf-8'>
<title>Login</title>
</head>
<body>
<h1>Login</h1>
<?php if (isset($_GET['registered'])) echo '<p>Registro completado, ya puedes iniciar sesión.</p>'; ?>
<?php if ($errors): ?>
<ul><?php foreach ($errors as $e) echo '<li>' . htmlspecialchars($e, ENT_QUOTES, 'UTF-8') . '</li>'; ?></ul>
<?php endif; ?>
<form method='post' action='index.php'>
<label>Email:<input type='email' name='email' required></label><br>
<label>Contraseña:<input type='password' name='password' required></label><br>
<input type='hidden' name='csrf' value='<?php echo htmlspecialchars($csrf); ?>'>
<button type='submit'>Entrar</button>
</form>
<p><a href='register.php'>Crear cuenta</a></p>
</body>
</html>
Archivo: public/dashboard.php (área privada)
<?php
// public/dashboard.php
require __DIR__ . '/../src/db.php';
$config = require __DIR__ . '/../src/config.php';
require __DIR__ . '/../src/functions.php';
start_secure_session($config);
require_login();
// Obtener datos del usuario
$stmt = $pdo->prepare('SELECT email, created_at FROM users WHERE id = ?');
$stmt->execute([$_SESSION['user_id']]);
$user = $stmt->fetch();
?>
<!doctype html>
<html lang='es'>
<head>
<meta charset='utf-8'>
<title>Dashboard</title>
</head>
<body>
<h1>Dashboard</h1>
<p>Bienvenido <?php echo htmlspecialchars($user['email'], ENT_QUOTES, 'UTF-8'); ?>.</p>
<p>Cuenta creada: <?php echo htmlspecialchars($user['created_at'], ENT_QUOTES, 'UTF-8'); ?>.</p>
<p><a href='logout.php'>Cerrar sesión</a></p>
</body>
</html>
Archivo: public/logout.php
<?php
// public/logout.php
$config = require __DIR__ . '/../src/config.php';
require __DIR__ . '/../src/functions.php';
start_secure_session($config);
// Destruir sesión de forma segura
$_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();
header('Location: index.php');
exit;
Por qué tomamos estas decisiones
- PDO con prepared statements: evita inyección SQL y es portable.
- password_hash / password_verify: maneja sal y algoritmos seguros (bcrypt/Argon2 si disponible).
- session_regenerate_id(true): previene fijación de sesión tras login.
- HttpOnly + Secure + SameSite en cookies: reduce riesgo de XSS/CSRF y robo de sesión.
- Tokens CSRF: protegen las operaciones por POST de solicitudes forjadas.
- Validación y saneamiento: evita datos inválidos o maliciosos en la entrada.
Notas prácticas y mejoras recomendadas
- Usa HTTPS siempre en producción (tene en cuenta 'cookie_secure' = true).
- Implementa límites de intentos de login y bloqueo temporal para mitigar fuerza bruta.
- Considera Argon2 (password_hash soporta PASSWORD_ARGON2ID) si está disponible en tu entorno.
- Añade verificación por email y 2FA para mayor seguridad.
- En producción, gestiona la configuración sensible vía variables de entorno y no en archivos de texto.
Siguiente paso: integra un sistema de bloqueo y registrador de intentos de login, y habilita autenticación multifactor para dotar al sistema de una capa extra de seguridad.
Advertencia de seguridad: este ejemplo es una base didáctica. Antes de desplegar a producción, revisa políticas de cifrado, protección contra ataques automatizados y auditoría de dependencias.
¿Quieres comentar?
Inicia sesión con Telegram para participar en la conversación