Proyecto práctico: Sistema de autenticación seguro en PHP con JWT y PDO
Voy a guiarte para crear una API pequeña en PHP que implemente registro, login, refresh token y acceso a recursos protegidos usando JWT (stateless) y PDO (consultas preparadas). El enfoque prioriza seguridad práctica: password_hash(), prepared statements, tokens de refresco almacenados en BD y cookies seguras.
Requisitos previos
- PHP 8.0+ (extensiones: pdo_mysql, openssl)
- Composer
- MySQL o MariaDB
- Servidor web (Apache/Nginx) apuntando a la carpeta
public/
Características
- Registro de usuario con
password_hash - Login que genera access token (JWT) y refresh token (largo, almacenado en BD)
- Endpoint
/profileprotegido por JWT - Refresh token endpoint
- Uso de PDO y prepared statements
Estructura de carpetas
project/
├─ public/
│ └─ index.php # Front controller (router simple)
├─ src/
│ ├─ Database.php # Conexión PDO
│ ├─ Auth.php # Lógica de tokens y auth
│ └─ UserController.php# Endpoints
├─ migrations/
│ └─ schema.sql
├─ .env.example
├─ composer.json
└─ README.md
.env.example
DB_HOST=127.0.0.1
DB_NAME=auth_example
DB_USER=root
DB_PASS=secret
JWT_SECRET=change_this_to_long_random_value
ACCESS_EXPIRE=900 # 15 minutos
REFRESH_EXPIRE=604800 # 7 días
Migración SQL (migrations/schema.sql)
CREATE TABLE users (
id INT AUTO_INCREMENT PRIMARY KEY,
email VARCHAR(255) NOT NULL UNIQUE,
password_hash VARCHAR(255) NOT NULL,
refresh_token VARCHAR(512),
refresh_expires_at DATETIME,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
composer.json
{
"require": {
"firebase/php-jwt": "^6.0",
"vlucas/phpdotenv": "^5.5"
},
"autoload": {
"psr-4": { "App\\": "src/" }
}
}
public/index.php
<?php
require __DIR__ . '/../vendor/autoload.php';
use App\UserController;
use Dotenv\Dotenv;
$dotenv = Dotenv::createImmutable(__DIR__ . '/../');
$dotenv->load();
$controller = new UserController();
$method = $_SERVER['REQUEST_METHOD'];
$path = parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH);
// Simple router
if ($method === 'POST' && $path === '/register') {
$controller->register();
exit;
}
if ($method === 'POST' && $path === '/login') {
$controller->login();
exit;
}
if ($method === 'POST' && $path === '/refresh') {
$controller->refresh();
exit;
}
if ($method === 'GET' && $path === '/profile') {
$controller->profile();
exit;
}
http_response_code(404);
echo json_encode(['error' => 'Not found']);
src/Database.php
<?php
namespace App;
class Database
{
private static $pdo;
public static function get()
{
if (self::$pdo) return self::$pdo;
$host = $_ENV['DB_HOST'];
$db = $_ENV['DB_NAME'];
$user = $_ENV['DB_USER'];
$pass = $_ENV['DB_PASS'];
$dsn = "mysql:host=$host;dbname=$db;charset=utf8mb4";
$opts = [
\PDO::ATTR_ERRMODE => \PDO::ERRMODE_EXCEPTION,
\PDO::ATTR_DEFAULT_FETCH_MODE => \PDO::FETCH_ASSOC,
];
self::$pdo = new \PDO($dsn, $user, $pass, $opts);
return self::$pdo;
}
}
src/Auth.php
<?php
namespace App;
use Firebase\JWT\JWT;
use Firebase\JWT\Key;
class Auth
{
public static function hashPassword(string $pwd): string
{
return password_hash($pwd, PASSWORD_DEFAULT);
}
public static function verifyPassword(string $pwd, string $hash): bool
{
return password_verify($pwd, $hash);
}
public static function generateAccessToken(array $payload): string
{
$secret = $_ENV['JWT_SECRET'];
$now = time();
$exp = $now + intval($_ENV['ACCESS_EXPIRE']);
$token = array_merge($payload, [
'iat' => $now,
'exp' => $exp
]);
return JWT::encode($token, $secret, 'HS256');
}
public static function verifyAccessToken(string $jwt)
{
try {
$secret = $_ENV['JWT_SECRET'];
return JWT::decode($jwt, new Key($secret, 'HS256'));
} catch (\Exception $e) {
return null;
}
}
public static function generateRefreshToken(): string
{
return bin2hex(random_bytes(64));
}
}
src/UserController.php
<?php
namespace App;
class UserController
{
private $pdo;
public function __construct()
{
$this->pdo = Database::get();
header('Content-Type: application/json; charset=utf-8');
}
private function jsonInput()
{
$raw = file_get_contents('php://input');
return json_decode($raw, true) ?? [];
}
public function register()
{
$data = $this->jsonInput();
$email = $data['email'] ?? '';
$password = $data['password'] ?? '';
if (!filter_var($email, FILTER_VALIDATE_EMAIL) || strlen($password) < 8) {
http_response_code(400);
echo json_encode(['error' => 'Invalid input']);
return;
}
$hash = Auth::hashPassword($password);
$stmt = $this->pdo->prepare('INSERT INTO users (email, password_hash) VALUES (:e, :p)');
try {
$stmt->execute([':e' => $email, ':p' => $hash]);
http_response_code(201);
echo json_encode(['message' => 'User created']);
} catch (\PDOException $e) {
http_response_code(409);
echo json_encode(['error' => 'User exists']);
}
}
public function login()
{
$data = $this->jsonInput();
$email = $data['email'] ?? '';
$password = $data['password'] ?? '';
$stmt = $this->pdo->prepare('SELECT id, password_hash FROM users WHERE email = :e');
$stmt->execute([':e' => $email]);
$user = $stmt->fetch();
if (!$user || !Auth::verifyPassword($password, $user['password_hash'])) {
http_response_code(401);
echo json_encode(['error' => 'Invalid credentials']);
return;
}
$access = Auth::generateAccessToken(['sub' => $user['id'], 'email' => $email]);
$refresh = Auth::generateRefreshToken();
$expiresAt = date('Y-m-d H:i:s', time() + intval($_ENV['REFRESH_EXPIRE']));
$upd = $this->pdo->prepare('UPDATE users SET refresh_token = :r, refresh_expires_at = :e WHERE id = :id');
$upd->execute([':r' => $refresh, ':e' => $expiresAt, ':id' => $user['id']]);
// Return tokens: access in body, refresh as HttpOnly cookie
setcookie('refresh_token', $refresh, [
'expires' => time() + intval($_ENV['REFRESH_EXPIRE']),
'httponly' => true,
'secure' => isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off',
'samesite' => 'Lax'
]);
echo json_encode(['access_token' => $access, 'token_type' => 'bearer', 'expires_in' => intval($_ENV['ACCESS_EXPIRE'])]);
}
public function refresh()
{
// Read refresh from cookie
$refresh = $_COOKIE['refresh_token'] ?? null;
if (!$refresh) {
http_response_code(401);
echo json_encode(['error' => 'No refresh token']);
return;
}
$stmt = $this->pdo->prepare('SELECT id, email, refresh_expires_at FROM users WHERE refresh_token = :r');
$stmt->execute([':r' => $refresh]);
$user = $stmt->fetch();
if (!$user || strtotime($user['refresh_expires_at']) <= time()) {
http_response_code(401);
echo json_encode(['error' => 'Invalid refresh token']);
return;
}
// Issue new access token and rotate refresh token
$access = Auth::generateAccessToken(['sub' => $user['id'], 'email' => $user['email']]);
$newRefresh = Auth::generateRefreshToken();
$expiresAt = date('Y-m-d H:i:s', time() + intval($_ENV['REFRESH_EXPIRE']));
$upd = $this->pdo->prepare('UPDATE users SET refresh_token = :r, refresh_expires_at = :e WHERE id = :id');
$upd->execute([':r' => $newRefresh, ':e' => $expiresAt, ':id' => $user['id']]);
setcookie('refresh_token', $newRefresh, [
'expires' => time() + intval($_ENV['REFRESH_EXPIRE']),
'httponly' => true,
'secure' => isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off',
'samesite' => 'Lax'
]);
echo json_encode(['access_token' => $access, 'expires_in' => intval($_ENV['ACCESS_EXPIRE'])]);
}
public function profile()
{
$auth = $_SERVER['HTTP_AUTHORIZATION'] ?? '';
if (!preg_match('/Bearer\s+(\S+)/', $auth, $m)) {
http_response_code(401);
echo json_encode(['error' => 'Token required']);
return;
}
$jwt = $m[1];
$data = Auth::verifyAccessToken($jwt);
if (!$data) {
http_response_code(401);
echo json_encode(['error' => 'Invalid token']);
return;
}
// Normally you'd query DB for user details; we'll return the token payload
echo json_encode(['user' => ['id' => $data->sub, 'email' => $data->email]]);
}
}
Por qué está diseñado así
- PDO con prepared statements evita inyecciones SQL.
- password_hash/verify delegan al algoritmo seguro de PHP (bcrypt/argon2 si está disponible).
- JWT como access token hace el acceso stateless y rápido; corto periodo de validez reduce impacto si se filtra.
- Refresh token: largo, almacenado en BD y rotado al usarlo. Si se roba el cookie, el token puede revocarse en servidor (limpiar campo).
- Se usa cookie HttpOnly para refresh token: más difícil de robar vía XSS. El access token se envía en Authorization para APIs.
Notas prácticas y despliegue
- Siempre servir por HTTPS. Cookies marcadas 'secure' solo se envían por TLS.
- Guarda JWT_SECRET en un vault/secret manager en producción; debe ser largo y aleatorio.
- Implementa rate limiting y bloqueo temporal tras varios intentos de login para evitar fuerza bruta.
- Considera usar SameSite=strict si tu front y API están en el mismo dominio y no necesitas navegación cross-site.
Con esto tienes un sistema minimal viable y seguro para autenticación en PHP. Como siguiente paso, añade invalidación de refresh tokens al hacer logout (limpiar campo en BD), lleva un historial de sesiones para poder revocar sesiones específicas y habilita logging y alertas sobre intentos sospechosos.
Consejo avanzado: implementa rotación de refresh tokens + lista de revocación con versión o jti en la tabla; así puedes invalidar tokens sin afectar otras sesiones. Y recuerda: nunca confíes en tokens si no están firmados y validados correctamente; registra y monitoriza intentos de abuso.
¿Quieres comentar?
Inicia sesión con Telegram para participar en la conversación