API REST con PHP 8: JWT, validación, PDO y rate limiting
Te muestro un ejemplo práctico y ligero para levantar una API REST en PHP 8 que incluya:
- Autenticación con JWT
- Acceso seguro a base de datos con PDO
- Validación básica de entrada
- Limitación de tasa (rate limiting) con Redis
La idea es centrarnos en patrones reutilizables: middleware de autorización, emisión de tokens y control de acceso.
Dependencias (Composer)
composer require nikic/fast-route firebase/php-jwt predis/predis
Estructura mínima
project/
├─ public/index.php
├─ src/db.php
├─ src/auth.php
├─ src/rate_limiter.php
└─ composer.json
1) Conexión PDO (src/db.php)
<?php
function getPDO(): PDO
{
$host = getenv('DB_HOST') ?: '127.0.0.1';
$db = getenv('DB_NAME') ?: 'test';
$user = getenv('DB_USER') ?: 'root';
$pass = getenv('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,
];
return new PDO($dsn, $user, $pass, $opts);
}
2) Autenticación y JWT (src/auth.php)
Usamos firebase/php-jwt. Guarda la clave en una variable de entorno (JWT_SECRET).
<?php
use Firebase\JWT\JWT;
use Firebase\JWT\Key;
function issueToken(array $payload): string
{
$secret = getenv('JWT_SECRET') ?: 'change_me';
$now = time();
$token = array_merge([
'iat' => $now,
'exp' => $now + 3600, // 1 hora
], $payload);
return JWT::encode($token, $secret, 'HS256');
}
function decodeToken(string $jwt): array
{
$secret = getenv('JWT_SECRET') ?: 'change_me';
try {
$decoded = JWT::decode($jwt, new Key($secret, 'HS256'));
return (array) $decoded;
} catch (\Throwable $e) {
throw new RuntimeException('Token inválido: ' . $e->getMessage());
}
}
function getBearerToken(): ?string
{
$hdr = $_SERVER['HTTP_AUTHORIZATION'] ?? ($_SERVER['REDIRECT_HTTP_AUTHORIZATION'] ?? null);
if (!$hdr) return null;
if (preg_match('/Bearer\s+(.*)$/i', $hdr, $m)) return $m[1];
return null;
}
3) Rate limiter simple con Redis (src/rate_limiter.php)
<?php
use Predis\Client;
function rateLimit(string $key, int $limit = 100, int $window = 60): bool
{
// key: prefijo + ip o usuario
$redisHost = getenv('REDIS_HOST') ?: '127.0.0.1';
$redis = new Client(['host' => $redisHost]);
$current = $redis->incr($key);
if ($current === 1) {
$redis->expire($key, $window);
}
return $current <= $limit;
}
4) Router y endpoints (public/index.php)
Ejemplo mínimo con FastRoute. Se asume que puedes usar mod_rewrite o configurar el webserver para apuntar a este archivo.
<?php
require __DIR__ . '/../vendor/autoload.php';
require __DIR__ . '/../src/db.php';
require __DIR__ . '/../src/auth.php';
require __DIR__ . '/../src/rate_limiter.php';
use FastRoute\RouteCollector;
$dispatcher = FastRoute\simpleDispatcher(function(RouteCollector $r) {
$r->addRoute('POST', '/login', 'login');
$r->addRoute('POST', '/users', 'create_user');
$r->addRoute('GET', '/users/{id:\\d+}', 'get_user');
});
// Fetch method and URI
$httpMethod = $_SERVER['REQUEST_METHOD'];
$uri = $_SERVER['REQUEST_URI'];
// Strip query string
if (false !== $pos = strpos($uri, '?')) {
$uri = substr($uri, 0, $pos);
}
$uri = rawurldecode($uri);
$routeInfo = $dispatcher->dispatch($httpMethod, $uri);
switch ($routeInfo[0]) {
case FastRoute\Dispatcher::NOT_FOUND:
http_response_code(404);
echo json_encode(['error' => 'Not Found']);
break;
case FastRoute\Dispatcher::METHOD_NOT_ALLOWED:
http_response_code(405);
echo json_encode(['error' => 'Method Not Allowed']);
break;
case FastRoute\Dispatcher::FOUND:
$handler = $routeInfo[1];
$vars = $routeInfo[2];
header('Content-Type: application/json');
// Simple per-IP rate limit
$ip = $_SERVER['REMOTE_ADDR'] ?? 'unknown';
$key = "rate:{$ip}";
if (!rateLimit($key, 100, 60)) {
http_response_code(429);
echo json_encode(['error' => 'Too Many Requests']);
break;
}
// Dispatch handlers
if ($handler === 'login') {
$body = json_decode(file_get_contents('php://input'), true) ?: [];
$email = $body['email'] ?? '';
$password = $body['password'] ?? '';
if (!filter_var($email, FILTER_VALIDATE_EMAIL) || !$password) {
http_response_code(400);
echo json_encode(['error' => 'Invalid input']);
break;
}
$pdo = getPDO();
$stmt = $pdo->prepare('SELECT id, password_hash FROM users WHERE email = ?');
$stmt->execute([$email]);
$user = $stmt->fetch();
if (!$user || !password_verify($password, $user['password_hash'])) {
http_response_code(401);
echo json_encode(['error' => 'Invalid credentials']);
break;
}
$token = issueToken(['sub' => $user['id']]);
echo json_encode(['token' => $token]);
break;
}
if ($handler === 'create_user') {
$body = json_decode(file_get_contents('php://input'), true) ?: [];
$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']);
break;
}
$pdo = getPDO();
$stmt = $pdo->prepare('INSERT INTO users (email, password_hash) VALUES (?, ?)');
$hash = password_hash($password, PASSWORD_DEFAULT);
try {
$stmt->execute([$email, $hash]);
http_response_code(201);
echo json_encode(['id' => $pdo->lastInsertId()]);
} catch (PDOException $e) {
http_response_code(500);
echo json_encode(['error' => 'Database error']);
}
break;
}
if ($handler === 'get_user') {
// Protected endpoint
$bearer = getBearerToken();
if (!$bearer) {
http_response_code(401);
echo json_encode(['error' => 'Token required']);
break;
}
try {
$payload = decodeToken($bearer);
} catch (RuntimeException $e) {
http_response_code(401);
echo json_encode(['error' => 'Invalid token']);
break;
}
$userId = (int) $vars['id'];
// Authorization: allow users to fetch only their own data (example)
if ($payload['sub'] !== $userId) {
http_response_code(403);
echo json_encode(['error' => 'Forbidden']);
break;
}
$pdo = getPDO();
$stmt = $pdo->prepare('SELECT id, email FROM users WHERE id = ?');
$stmt->execute([$userId]);
$u = $stmt->fetch();
if (!$u) {
http_response_code(404);
echo json_encode(['error' => 'User not found']);
break;
}
echo json_encode($u);
break;
}
http_response_code(500);
echo json_encode(['error' => 'Unhandled route']);
break;
}
Buenas prácticas y puntos importantes
- Usa prepared statements (PDO) para evitar SQL injection — ya está aplicado en los ejemplos.
- No guardes secretos en el repositorio: usa variables de entorno o un secret manager para JWT_SECRET y credenciales DB/Redis.
- Haz que los tokens expiren y considera un mecanismo de refresh tokens si necesitas sesiones persistentes.
- Valida siempre el input (tipo, longitud, formato) antes de operar sobre la base de datos.
- Si necesitas mayor propósitos de seguridad (rotación de claves, múltiples algoritmos) implementa 'kid' y JWKs, y usa librerías que gestionen esa complejidad.
Implementa pruebas automáticas (unitarias y de integración) para las rutas y la lógica de autorización. Como siguiente paso natural puedes añadir una capa de caching para endpoints de lectura y un endpoint de revocación de tokens usando una lista negra almacenada en Redis o en una tabla de DB.
Consejo avanzado: evita tokens JWT largos y permanentes. Emite access tokens cortos (p. ej. 15–60 min) y maneja refresh tokens con control de revocación en servidor; además, almacena la versión del token (token version) en la base de datos para poder invalidar tokens cuando cambie la contraseña o se detecte compromiso de cuenta.
¿Quieres comentar?
Inicia sesión con Telegram para participar en la conversación