API de autenticación segura en PHP (JWT + Refresh Tokens) sin frameworks
Este tutorial te guía para construir una API REST mínima en PHP que implementa autenticación basada en JWT para acceso corto y refresh tokens para rotación segura. Usaremos PDO, password_hash, firebase/php-jwt y vlucas/phpdotenv. El objetivo es un sistema simple, seguro y fácil de entender.
Requisitos previos
- PHP 8.0+ con ext-pdo y ext-openssl
- Composer
- MySQL/MariaDB
- Conocimientos básicos de HTTP y JWT
Estructura de carpetas
auth-php-jwt/
├── composer.json
├── .env
├── migrations.sql
├── public/
│ └── index.php
├── src/
│ ├── Config.php
│ ├── Database.php
│ ├── UserModel.php
│ └── Auth.php
└── vendor/
Instalación inicial
# Inicializar composer
composer init --name="tu/namespace" --require="firebase/php-jwt:^6.0" --require="vlucas/phpdotenv:^5.0" -n
composer install
O usa este composer.json:
{
"require": {
"firebase/php-jwt": "^6.0",
"vlucas/phpdotenv": "^5.0"
},
"autoload": {
"psr-4": {
"App\\": "src/"
}
}
}
Luego ejecuta composer dump-autoload.
.env (muestra)
APP_ENV=development
DB_DSN=mysql:host=127.0.0.1;dbname=auth_db;charset=utf8mb4
DB_USER=root
DB_PASS=secret
JWT_SECRET=una_clave_muy_larga_y_segura
ACCESS_TOKEN_EXP=900 # 15 minutos
REFRESH_TOKEN_EXP=1209600 # 14 días (en segundos)
Migraciones SQL
-- 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
);
CREATE TABLE refresh_tokens (
id INT AUTO_INCREMENT PRIMARY KEY,
user_id INT NOT NULL,
token_hash VARCHAR(255) NOT NULL,
expires_at DATETIME NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
revoked TINYINT(1) DEFAULT 0,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
Archivo: src/Config.php
load();
self::$env = $_ENV;
}
public static function get(string $key, $default = null)
{
return self::$env[$key] ?? $default;
}
}
Archivo: src/Database.php
PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
]);
} catch (PDOException $e) {
http_response_code(500);
echo json_encode(['error' => 'DB connection failed']);
exit;
}
return self::$pdo;
}
}
Archivo: src/UserModel.php
db = Database::get();
}
public function create(string $email, string $password): int
{
$hash = password_hash($password, PASSWORD_ARGON2ID);
$stmt = $this->db->prepare('INSERT INTO users (email, password) VALUES (:email, :password)');
$stmt->execute(['email' => $email, 'password' => $hash]);
return (int)$this->db->lastInsertId();
}
public function findByEmail(string $email): ?array
{
$stmt = $this->db->prepare('SELECT * FROM users WHERE email = :email');
$stmt->execute(['email' => $email]);
$row = $stmt->fetch();
return $row ?: null;
}
public function findById(int $id): ?array
{
$stmt = $this->db->prepare('SELECT id, email, created_at FROM users WHERE id = :id');
$stmt->execute(['id' => $id]);
$row = $stmt->fetch();
return $row ?: null;
}
public function storeRefreshToken(int $userId, string $tokenHash, string $expiresAt): void
{
$stmt = $this->db->prepare('INSERT INTO refresh_tokens (user_id, token_hash, expires_at) VALUES (:user_id, :token_hash, :expires_at)');
$stmt->execute(['user_id' => $userId, 'token_hash' => $tokenHash, 'expires_at' => $expiresAt]);
}
public function revokeRefreshToken(int $userId, string $tokenHash): void
{
$stmt = $this->db->prepare('UPDATE refresh_tokens SET revoked = 1 WHERE user_id = :user_id AND token_hash = :token_hash');
$stmt->execute(['user_id' => $userId, 'token_hash' => $tokenHash]);
}
public function findValidRefreshToken(int $userId, string $token): ?array
{
$stmt = $this->db->prepare('SELECT * FROM refresh_tokens WHERE user_id = :user_id AND revoked = 0 AND expires_at > NOW()');
$stmt->execute(['user_id' => $userId]);
$rows = $stmt->fetchAll();
foreach ($rows as $row) {
if (password_verify($token, $row['token_hash'])) {
return $row;
}
}
return null;
}
}
Archivo: src/Auth.php
$now,
'exp' => $now + (int)Config::get('ACCESS_TOKEN_EXP', 900),
'sub' => $user['id'],
'email' => $user['email']
];
return JWT::encode($payload, self::secret(), 'HS256');
}
public static function decodeAccessToken(string $jwt): ?array
{
try {
$decoded = JWT::decode($jwt, new Key(self::secret(), 'HS256'));
return (array)$decoded;
} catch (\Throwable $e) {
return null;
}
}
public static function generateRefreshToken(): string
{
return bin2hex(random_bytes(64));
}
public static function hashRefreshToken(string $token): string
{
return password_hash($token, PASSWORD_ARGON2ID);
}
}
Archivo público: public/index.php
'Invalid input'], 422);
}
if ($userModel->findByEmail($email)) {
respond(['error' => 'Email already exists'], 409);
}
$userId = $userModel->create($email, $password);
respond(['message' => 'User created', 'user_id' => $userId], 201);
}
if ($path === '/login' && $method === 'POST') {
$email = $input['email'] ?? '';
$password = $input['password'] ?? '';
$user = $userModel->findByEmail($email);
if (!$user || !password_verify($password, $user['password'])) {
respond(['error' => 'Invalid credentials'], 401);
}
$accessToken = Auth::generateAccessToken($user);
$refreshToken = Auth::generateRefreshToken();
$refreshHash = Auth::hashRefreshToken($refreshToken);
$expiresAt = date('Y-m-d H:i:s', time() + (int)Config::get('REFRESH_TOKEN_EXP', 1209600));
$userModel->storeRefreshToken((int)$user['id'], $refreshHash, $expiresAt);
respond(['access_token' => $accessToken, 'refresh_token' => $refreshToken]);
}
if ($path === '/refresh' && $method === 'POST') {
$refreshToken = $input['refresh_token'] ?? '';
$accessToken = $input['access_token'] ?? null; // optional for rotation
if (!$refreshToken) {
respond(['error' => 'Missing refresh token'], 400);
}
// If access token present, try to decode to get user id (not required)
$userId = null;
if ($accessToken) {
$decoded = Auth::decodeAccessToken($accessToken);
$userId = $decoded['sub'] ?? null;
}
// If we don't have userId, we need to search among refresh tokens: but refresh tokens are per user.
// For simplicity we will require a 'user_id' in body if access_token absent.
if (!$userId) {
$userId = $input['user_id'] ?? null;
}
if (!$userId) {
respond(['error' => 'Missing user identifier'], 400);
}
$valid = $userModel->findValidRefreshToken((int)$userId, $refreshToken);
if (!$valid) {
respond(['error' => 'Invalid or expired refresh token'], 401);
}
// Rotate: revoke old token and issue new refresh + access
$userModel->revokeRefreshToken((int)$userId, $valid['token_hash']);
$newRefresh = Auth::generateRefreshToken();
$newHash = Auth::hashRefreshToken($newRefresh);
$expiresAt = date('Y-m-d H:i:s', time() + (int)Config::get('REFRESH_TOKEN_EXP', 1209600));
$userModel->storeRefreshToken((int)$userId, $newHash, $expiresAt);
$user = $userModel->findById((int)$userId);
$newAccess = Auth::generateAccessToken($user);
respond(['access_token' => $newAccess, 'refresh_token' => $newRefresh]);
}
if ($path === '/logout' && $method === 'POST') {
$refreshToken = $input['refresh_token'] ?? '';
$userId = $input['user_id'] ?? null;
if (!$refreshToken || !$userId) {
respond(['error' => 'Missing parameters'], 400);
}
// Revoke any matching token for user
$valid = $userModel->findValidRefreshToken((int)$userId, $refreshToken);
if ($valid) {
$userModel->revokeRefreshToken((int)$userId, $valid['token_hash']);
}
respond(['message' => 'Logged out']);
}
if ($path === '/me' && $method === 'GET') {
$authHeader = $_SERVER['HTTP_AUTHORIZATION'] ?? '';
if (!$authHeader || !str_starts_with($authHeader, 'Bearer ')) {
respond(['error' => 'Missing token'], 401);
}
$token = trim(substr($authHeader, 7));
$decoded = Auth::decodeAccessToken($token);
if (!$decoded) {
respond(['error' => 'Invalid token'], 401);
}
$user = $userModel->findById((int)$decoded['sub']);
if (!$user) respond(['error' => 'User not found'], 404);
respond(['user' => $user]);
}
// default
respond(['message' => 'Not found'], 404);
Por qué este diseño
- JWT como access token: rápido y sin consultas a DB para validar (hasta expiración).
- Refresh tokens: almacenados en BD (hashados) para poder revocar/rotar. No guardes el refresh token en texto plano.
- Rotación: cuando se usa un refresh token se revoca y se emite uno nuevo; esto limita el daño si un token se filtra.
- password_hash + Argon2id: proteges contraseñas y tokens almacenados.
- Separación de responsabilidades: UserModel para BD, Auth para tokens.
Consideraciones de seguridad
- Almacena JWT_SECRET de forma segura (no en repositorio). Usa un secret fuerte y rotalo si es necesario.
- Envía tokens en cookies HttpOnly + Secure si la API va a ser consumida por navegadores; para APIs públicas considera CORS y almacenamiento en memoria o secure storage.
- Limita la vida del access token (p. ej. 5-15 minutos) y del refresh token, y registra actividad para detectar abuso.
- Protege endpoints críticos con rate limiting.
- Usa TLS siempre.
Puedes probar las rutas con curl o Postman: registro en /register, login en /login (obtendrás access + refresh token), acceder a /me con Authorization: Bearer <access_token>, refrescar con /refresh.
Siguiente paso: añade un sistema de invalidación global para JWTs (p. ej. un campo token_version en users y colocarlo dentro del JWT; al rotar o forzar logout incrementas la versión y validas que coincida) o implementa almacenamiento de tokens en Redis para revocación rápida y expiración automática.
Advertencia de seguridad: nunca guardes refresh tokens en localStorage para aplicaciones web; usa cookies HttpOnly y con SameSite apropiado para mitigar XSS/CSRF según tu flujo.
¿Quieres comentar?
Inicia sesión con Telegram para participar en la conversación