API REST en PHP (sin framework) con JWT y refresh tokens — Implementación segura
Proyecto práctico: construir una API REST mínima en PHP (sin framework) con autenticación basada en JWT, refresh tokens seguros (hasheados en BD), middleware de protección y buenas prácticas de seguridad.
Requisitos previos
- PHP 8.0+ (PDO, OpenSSL)
- Composer
- MySQL o MariaDB
- Extensión openssl habilitada (para random_bytes)
- Conocimientos básicos de JWT y HTTP
Estructura de carpetas
<project-root>
├── composer.json
├── config.php
├── migrations.sql
├── public
│ └── index.php # punto de entrada (document root)
└── src
├── Database.php
├── UserModel.php
├── Auth.php
└── Middleware.php
Instalación rápida
- composer require firebase/php-jwt
- Crear la base de datos y ejecutar migrations.sql
- Configurar config.php
- Apuntar el document root a la carpeta public/
migrations.sql
-- Usuarios
CREATE TABLE users (
id INT AUTO_INCREMENT PRIMARY KEY,
email VARCHAR(255) NOT NULL UNIQUE,
password_hash VARCHAR(255) NOT NULL,
role VARCHAR(50) DEFAULT 'user',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- Refresh tokens (guardamos hash del token para evitar almacenarlo en claro)
CREATE TABLE refresh_tokens (
id INT AUTO_INCREMENT PRIMARY KEY,
user_id INT NOT NULL,
token_hash CHAR(64) NOT NULL,
expires_at DATETIME NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
ip VARCHAR(45),
user_agent TEXT,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
INDEX (token_hash)
);
composer.json (mínimo)
{
"require": {
"firebase/php-jwt": "^6.0"
},
"autoload": {
"psr-4": { "App\\": "src/" }
}
}
config.php
<?php
return [
'db' => [
'dsn' => 'mysql:host=127.0.0.1;dbname=myapp;charset=utf8mb4',
'user' => 'dbuser',
'pass' => 'dbpass',
],
// Clave secreta para firmar JWT. Úsala desde una variable de entorno.
'jwt_secret' => getenv('JWT_SECRET') ?: 'please-change-this-secret',
// Duraciones
'access_ttl' => 900, // 15 minutos
'refresh_ttl' => 60 * 60 * 24 * 30 // 30 días
];
src/Database.php
<?php
namespace App;
use PDO;
class Database
{
private static ?PDO $pdo = null;
public static function get(array $config): PDO
{
if (self::$pdo === null) {
self::$pdo = new PDO(
$config['db']['dsn'],
$config['db']['user'],
$config['db']['pass'],
[PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION]
);
}
return self::$pdo;
}
}
src/UserModel.php
<?php
namespace App;
use PDO;
class UserModel
{
private PDO $pdo;
public function __construct(PDO $pdo)
{
$this->pdo = $pdo;
}
public function create(string $email, string $password): int
{
$hash = password_hash($password, PASSWORD_DEFAULT);
$stmt = $this->pdo->prepare('INSERT INTO users (email, password_hash) VALUES (:email, :hash)');
$stmt->execute(['email' => $email, 'hash' => $hash]);
return (int)$this->pdo->lastInsertId();
}
public function findByEmail(string $email): ?array
{
$stmt = $this->pdo->prepare('SELECT id, email, password_hash, role FROM users WHERE email = :email');
$stmt->execute(['email' => $email]);
$row = $stmt->fetch(PDO::FETCH_ASSOC);
return $row ?: null;
}
public function findById(int $id): ?array
{
$stmt = $this->pdo->prepare('SELECT id, email, role FROM users WHERE id = :id');
$stmt->execute(['id' => $id]);
$row = $stmt->fetch(PDO::FETCH_ASSOC);
return $row ?: null;
}
}
src/Auth.php
<?php
namespace App;
use Firebase\JWT\JWT;
use PDO;
class Auth
{
private PDO $pdo;
private array $config;
public function __construct(PDO $pdo, array $config)
{
$this->pdo = $pdo;
$this->config = $config;
}
public function issueAccessToken(array $user): string
{
$now = time();
$payload = [
'iat' => $now,
'exp' => $now + $this->config['access_ttl'],
'sub' => $user['id'],
'email' => $user['email'],
'role' => $user['role'] ?? 'user'
];
return JWT::encode($payload, $this->config['jwt_secret'], 'HS256');
}
// Crea y guarda un refresh token (almacenamos hash)
public function createRefreshToken(int $userId, string $ip = null, string $ua = null): string
{
$token = rtrim(strtr(base64_encode(random_bytes(64)), '+/', '-_'), '=');
$hash = hash('sha256', $token);
$expiresAt = (new \DateTime())->modify('+' . $this->config['refresh_ttl'] . ' seconds')->format('Y-m-d H:i:s');
$stmt = $this->pdo->prepare('INSERT INTO refresh_tokens (user_id, token_hash, expires_at, ip, user_agent) VALUES (:uid, :hash, :exp, :ip, :ua)');
$stmt->execute(['uid' => $userId, 'hash' => $hash, 'exp' => $expiresAt, 'ip' => $ip, 'ua' => $ua]);
return $token; // devuelto al cliente en texto claro una sola vez
}
public function rotateRefreshToken(string $oldToken, int $userId, string $ip = null, string $ua = null): ?string
{
// Borrar token viejo y emitir uno nuevo en una transacción
$this->pdo->beginTransaction();
try {
$oldHash = hash('sha256', $oldToken);
$stmt = $this->pdo->prepare('SELECT id, expires_at FROM refresh_tokens WHERE token_hash = :h AND user_id = :uid');
$stmt->execute(['h' => $oldHash, 'uid' => $userId]);
$row = $stmt->fetch(PDO::FETCH_ASSOC);
if (! $row) {
$this->pdo->rollBack();
return null;
}
$expires = new \DateTime($row['expires_at']);
if ($expires < new \DateTime()) {
// Expired: remove and bail
$del = $this->pdo->prepare('DELETE FROM refresh_tokens WHERE id = :id');
$del->execute(['id' => $row['id']]);
$this->pdo->commit();
return null;
}
// Delete old token
$del = $this->pdo->prepare('DELETE FROM refresh_tokens WHERE id = :id');
$del->execute(['id' => $row['id']]);
// Create new token
$newToken = $this->createRefreshToken($userId, $ip, $ua);
$this->pdo->commit();
return $newToken;
} catch (\Throwable $e) {
$this->pdo->rollBack();
throw $e;
}
}
public function validateRefreshToken(string $token): ?array
{
$hash = hash('sha256', $token);
$stmt = $this->pdo->prepare('SELECT rt.id, rt.user_id, rt.expires_at, u.email FROM refresh_tokens rt JOIN users u ON u.id = rt.user_id WHERE rt.token_hash = :h');
$stmt->execute(['h' => $hash]);
$row = $stmt->fetch(PDO::FETCH_ASSOC);
if (! $row) return null;
$expires = new \DateTime($row['expires_at']);
if ($expires < new \DateTime()) {
// Token expirado: borra
$del = $this->pdo->prepare('DELETE FROM refresh_tokens WHERE id = :id');
$del->execute(['id' => $row['id']]);
return null;
}
return $row; // contiene user_id
}
public function revokeRefreshToken(string $token): void
{
$hash = hash('sha256', $token);
$stmt = $this->pdo->prepare('DELETE FROM refresh_tokens WHERE token_hash = :h');
$stmt->execute(['h' => $hash]);
}
}
src/Middleware.php
<?php
namespace App;
use Firebase\JWT\JWT;
use Firebase\JWT\Key;
class Middleware
{
private array $config;
public function __construct(array $config)
{
$this->config = $config;
}
// Extrae el usuario desde el header Authorization: Bearer <token>
public function authFromRequest(callable $next)
{
return function ($req) use ($next) {
$headers = getallheaders();
$auth = $headers['Authorization'] ?? $headers['authorization'] ?? null;
if (! $auth || ! preg_match('/Bearer\s+(\S+)/', $auth, $m)) {
http_response_code(401);
echo json_encode(['error' => 'missing_token']);
return;
}
$token = $m[1];
try {
$decoded = JWT::decode($token, new Key($this->config['jwt_secret'], 'HS256'));
// attach user info to request (simple associative array in this example)
$req['user'] = ['id' => $decoded->sub, 'email' => $decoded->email, 'role' => $decoded->role];
return $next($req);
} catch (\Throwable $e) {
http_response_code(401);
echo json_encode(['error' => 'invalid_token', 'message' => $e->getMessage()]);
return;
}
};
}
}
public/index.php
<?php
require __DIR__ . '/../vendor/autoload.php';
use App\Database;
use App\UserModel;
use App\Auth;
use App\Middleware;
header('Content-Type: application/json; charset=utf-8');
$config = require __DIR__ . '/../config.php';
$pdo = Database::get($config);
$userModel = new UserModel($pdo);
$auth = new Auth($pdo, $config);
$middleware = new Middleware($config);
// Simple router
$method = $_SERVER['REQUEST_METHOD'];
$path = parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH');
// Helpers
$body = json_decode(file_get_contents('php://input') ?: '{}', true) ?: [];
// Endpoints
if ($method === 'POST' && $path === '/register') {
$email = $body['email'] ?? '';
$password = $body['password'] ?? '';
if (!filter_var($email, FILTER_VALIDATE_EMAIL) || strlen($password) < 8) {
http_response_code(400);
echo json_encode(['error' => 'invalid_input']);
exit;
}
try {
$id = $userModel->create($email, $password);
echo json_encode(['id' => $id, 'email' => $email]);
} catch (\PDOException $e) {
http_response_code(400);
echo json_encode(['error' => 'user_exists']);
}
exit;
}
if ($method === 'POST' && $path === '/login') {
$email = $body['email'] ?? '';
$password = $body['password'] ?? '';
$user = $userModel->findByEmail($email);
if (! $user || ! password_verify($password, $user['password_hash'])) {
http_response_code(401);
echo json_encode(['error' => 'invalid_credentials']);
exit;
}
$access = $auth->issueAccessToken($user);
$refresh = $auth->createRefreshToken($user['id'], $_SERVER['REMOTE_ADDR'] ?? null, $_SERVER['HTTP_USER_AGENT'] ?? null);
echo json_encode(['access_token' => $access, 'expires_in' => $config['access_ttl'], 'refresh_token' => $refresh]);
exit;
}
if ($method === 'POST' && $path === '/refresh') {
$refresh = $body['refresh_token'] ?? '';
if (! $refresh) {
http_response_code(400);
echo json_encode(['error' => 'missing_refresh']);
exit;
}
$valid = $auth->validateRefreshToken($refresh);
if (! $valid) {
http_response_code(401);
echo json_encode(['error' => 'invalid_refresh']);
exit;
}
// rotate
$newRefresh = $auth->rotateRefreshToken($refresh, (int)$valid['user_id'], $_SERVER['REMOTE_ADDR'] ?? null, $_SERVER['HTTP_USER_AGENT'] ?? null);
if (! $newRefresh) {
http_response_code(401);
echo json_encode(['error' => 'invalid_refresh']);
exit;
}
$user = $userModel->findById((int)$valid['user_id']);
$access = $auth->issueAccessToken($user);
echo json_encode(['access_token' => $access, 'expires_in' => $config['access_ttl'], 'refresh_token' => $newRefresh]);
exit;
}
if ($method === 'POST' && $path === '/logout') {
$refresh = $body['refresh_token'] ?? '';
if ($refresh) {
$auth->revokeRefreshToken($refresh);
}
echo json_encode(['ok' => true]);
exit;
}
// Protected route example: GET /me
if ($method === 'GET' && $path === '/me') {
$handler = function ($req) use ($userModel) {
$user = $userModel->findById((int)$req['user']['id']);
echo json_encode(['user' => $user]);
};
$protected = $middleware->authFromRequest($handler);
$protected([]);
exit;
}
http_response_code(404);
echo json_encode(['error' => 'not_found']);
Por qué estas decisiones (explicación)
- Usar JWT firmado (HS256) para tokens de acceso: fácil de verificar en cada petición sin tocar la BD para cada request. Buen trade-off para tokens cortos (15 min).
- Refresh tokens long-lived y hasheados en BD: nunca almacenes el token en claro. Si la BD se filtra, hashes hacen más difícil su uso. Además permite revocar tokens (logout).
- Rotación de refresh tokens: al intercambiar un refresh token, borramos el antiguo y creamos uno nuevo para mitigar el uso de tokens robados (replay).
- Usar password_hash / password_verify: delega el algoritmo y ajustes de cost a PHP (bcrypt/argon2 si disponible).
- PDO con prepared statements: evita inyección SQL.
- No confiamos en datos del JWT para operaciones sensibles: cuando necesites información crítica, recarga desde la BD si es necesario.
Pruebas básicas (curl)
# Register
curl -X POST -H 'Content-Type: application/json' -d '{"email":"user@example.com","password":"secret123"}' http://localhost/register
# Login
curl -X POST -H 'Content-Type: application/json' -d '{"email":"user@example.com","password":"secret123"}' http://localhost/login
# Use access token
curl -H 'Authorization: Bearer <access_token>' http://localhost/me
# Refresh
curl -X POST -H 'Content-Type: application/json' -d '{"refresh_token":"<refresh>"}' http://localhost/refresh
Buenas prácticas y consideraciones de seguridad
- Usa HTTPS siempre: los tokens en tránsito deben estar cifrados.
- Guarda la secret <<jwt_secret>> en variables de entorno, no en el repo.
- Considera implementar limitadores de velocidad (rate limiting) en endpoints de login/refresh.
- Para aplicaciones web: guarda refresh tokens en cookies HttpOnly y SameSite=strict si es viable; evita exposición en JavaScript.
- Audita el uso de refresh tokens (ip, user_agent) y detecta anomalías.
Siguiente paso: implementa rotación completa con detección de reuse — si un refresh token ya rotado aparece, revoca todos los tokens del usuario y fuerza re-login. Esto mitiga ataques donde un token robado se usa después de haber sido rotado.
Advertencia avanzada: revocar tokens y logout seguro es complejo en arquitecturas distribuidas. Considera una lista de revocación corta en memoria o un store rápido (Redis) para invalidar tokens a nivel global sin consultar la BD en cada request.
¿Quieres comentar?
Inicia sesión con Telegram para participar en la conversación