API RESTful en PHP con JWT y PDO — tutorial práctico
Construiremos una API mínima en PHP que permite registro, login y acceso a un recurso protegido mediante JWT. Usaremos PDO para consultas seguras a MySQL y firebase/php-jwt para tokens. El objetivo es práctico: código funcional y explicaciones de por qué se toman ciertas decisiones.
Requisitos previos
- PHP ≥ 7.4 con extensiones
pdo_mysqlyopenssl - Composer
- MySQL o MariaDB
- Servidor web que apunte la raíz a la carpeta
public/(Apache, Nginx)
Estructura de carpetas
project/
├─ public/
│ └─ index.php
├─ src/
│ ├─ Database.php
│ ├─ User.php
│ └─ AuthController.php
├─ vendor/
├─ .env
├─ composer.json
└─ sql/
└─ schema.sql
Dependencias
Instala con Composer:
composer require firebase/php-jwt vlucas/phpdotenv
Esquema SQL
-- sql/schema.sql
CREATE TABLE users (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(100) NOT NULL,
email VARCHAR(255) NOT NULL UNIQUE,
password_hash VARCHAR(255) NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
.env (ejemplo)
DB_HOST=127.0.0.1
DB_NAME=myapi
DB_USER=myuser
DB_PASS=mypass
JWT_SECRET=una_clave_muy_larga_y_segura
JWT_ISSUER=http://localhost
JWT_AUDIENCE=http://localhost
JWT_EXP=900 # segundos (15 minutos)
Archivo: src/Database.php
<?php
namespace App;
use PDO;
use PDOException;
class Database
{
private static $pdo;
public static function getConnection(): PDO
{
if (self::$pdo === null) {
$host = getenv('DB_HOST');
$db = getenv('DB_NAME');
$user = getenv('DB_USER');
$pass = getenv('DB_PASS');
$dsn = "mysql:host={$host};dbname={$db};charset=utf8mb4";
$options = [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
PDO::ATTR_EMULATE_PREPARES => false,
];
try {
self::$pdo = new PDO($dsn, $user, $pass, $options);
} catch (PDOException $e) {
http_response_code(500);
echo json_encode(["error" => "DB connection failed"]);
exit;
}
}
return self::$pdo;
}
}
Archivo: src/User.php
<?php
namespace App;
use PDO;
class User
{
private $pdo;
public function __construct(PDO $pdo)
{
$this->pdo = $pdo;
}
public function create(string $name, string $email, string $password): array
{
$hash = password_hash($password, PASSWORD_DEFAULT);
$stmt = $this->pdo->prepare('INSERT INTO users (name, email, password_hash) VALUES (:name, :email, :hash)');
$stmt->execute(['name' => $name, 'email' => $email, 'hash' => $hash]);
return [
'id' => (int)$this->pdo->lastInsertId(),
'name' => $name,
'email' => $email,
];
}
public function findByEmail(string $email): ?array
{
$stmt = $this->pdo->prepare('SELECT id, name, email, password_hash FROM users WHERE email = :email');
$stmt->execute(['email' => $email]);
$user = $stmt->fetch();
return $user ?: null;
}
}
Archivo: src/AuthController.php
<?php
namespace App;
use Firebase\JWT\JWT;
use Firebase\JWT\Key;
use DateTimeImmutable;
class AuthController
{
private $userModel;
private $jwtSecret;
private $issuer;
private $aud;
private $exp;
public function __construct(User $userModel)
{
$this->userModel = $userModel;
$this->jwtSecret = getenv('JWT_SECRET');
$this->issuer = getenv('JWT_ISSUER');
$this->aud = getenv('JWT_AUDIENCE');
$this->exp = (int)getenv('JWT_EXP') ?: 900;
}
public function register(array $data)
{
if (empty($data['name']) || empty($data['email']) || empty($data['password'])) {
http_response_code(422);
echo json_encode(['error' => 'Missing fields']);
return;
}
// Simple validation; expand as needed
if (!filter_var($data['email'], FILTER_VALIDATE_EMAIL)) {
http_response_code(422);
echo json_encode(['error' => 'Invalid email']);
return;
}
// Check exists
if ($this->userModel->findByEmail($data['email'])) {
http_response_code(409);
echo json_encode(['error' => 'Email already registered']);
return;
}
$user = $this->userModel->create($data['name'], $data['email'], $data['password']);
http_response_code(201);
echo json_encode(['user' => $user]);
}
public function login(array $data)
{
if (empty($data['email']) || empty($data['password'])) {
http_response_code(422);
echo json_encode(['error' => 'Missing fields']);
return;
}
$user = $this->userModel->findByEmail($data['email']);
if (!$user || !password_verify($data['password'], $user['password_hash'])) {
http_response_code(401);
echo json_encode(['error' => 'Invalid credentials']);
return;
}
$now = new DateTimeImmutable();
$exp = $now->modify("+{$this->exp} seconds")->getTimestamp();
$payload = [
'iat' => $now->getTimestamp(),
'iss' => $this->issuer,
'nbf' => $now->getTimestamp(),
'exp' => $exp,
'aud' => $this->aud,
'sub' => $user['id'],
'email' => $user['email']
];
$jwt = JWT::encode($payload, $this->jwtSecret, 'HS256');
echo json_encode(['access_token' => $jwt, 'token_type' => 'Bearer', 'expires_in' => $this->exp]);
}
public function me(array $claims)
{
// claims comes from the validated token
echo json_encode(['user' => ['id' => $claims->sub, 'email' => $claims->email]]);
}
}
Archivo público: public/index.php
<?php
require __DIR__ . '/../vendor/autoload.php';
use App\Database;
use App\User;
use App\AuthController;
use Firebase\JWT\JWT;
use Firebase\JWT\Key;
// Load env
$dotenv = Dotenv\Dotenv::createImmutable(__DIR__ . '/..');
$dotenv->load();
header('Content-Type: application/json; charset=utf-8');
$method = $_SERVER['REQUEST_METHOD'];
$path = parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH);
$pdo = Database::getConnection();
$userModel = new User($pdo);
$auth = new AuthController($userModel);
// Simple router
if ($path === '/register' && $method === 'POST') {
$data = json_decode(file_get_contents('php://input'), true);
$auth->register($data ?? []);
exit;
}
if ($path === '/login' && $method === 'POST') {
$data = json_decode(file_get_contents('php://input'), true);
$auth->login($data ?? []);
exit;
}
// Protected route example: /me
if ($path === '/me' && $method === 'GET') {
$authHeader = $_SERVER['HTTP_AUTHORIZATION'] ?? '';
if (!preg_match('/Bearer\s+(.*)$/i', $authHeader, $matches)) {
http_response_code(401);
echo json_encode(['error' => 'No token provided']);
exit;
}
$token = $matches[1];
try {
$secret = getenv('JWT_SECRET');
$decoded = JWT::decode($token, new Key($secret, 'HS256'));
// Optionally: validate aud/iss here
$auth->me($decoded);
} catch (Exception $e) {
http_response_code(401);
echo json_encode(['error' => 'Token invalid or expired', 'message' => $e->getMessage()]);
}
exit;
}
http_response_code(404);
echo json_encode(['error' => 'Not found']);
Pruebas rápidas (curl)
# Registrar
curl -X POST http://localhost/register \
-H "Content-Type: application/json" \
-d '{"name":"Juan","email":"juan@example.com","password":"secret123"}'
# Login
curl -X POST http://localhost/login \
-H "Content-Type: application/json" \
-d '{"email":"juan@example.com","password":"secret123"}'
# Acceder a /me con el token devuelto
curl -H "Authorization: Bearer TOKEN" http://localhost/me
Por qué así (decisiones importantes)
- PDO con prepared statements: evita inyección SQL y es estándar en PHP.
- password_hash / password_verify: hashing fuerte y actualización automática de algoritmos.
- JWT firmado con HS256 y secreto robusto: permite que la API sea stateless y escalable. No almacenes información sensible en el payload.
- Token con expiración corta (ej. 15 min): limita el impacto en caso de fuga. Implementa refresh tokens si necesitas sesiones largas.
- Entry point único (public/index.php): facilita control de CORS, logs y middleware centralizado.
- Uso de dotenv: evita hardcodear credenciales en el código.
Mejoras y consideraciones de seguridad
- Forzar HTTPS en producción para proteger Authorization headers.
- Rotación de claves y soporte para tokens revocables (lista negra o usar revocation table).
- Limitar intentos de login (rate limiting) y registrar intentos fallidos.
- Validar aud y iss en el JWT para prevenir uso en contextos distintos.
- Considera usar algoritmos asimétricos (RS256) si necesitas delegar firma/validación entre servicios.
Si vas a seguir: implementa refresh tokens seguros (almacenados y rotados), añade validación más robusta de inputs (length, blacklist de password simples), y crea un middleware reutilizable para rutas protegidas. Una advertencia clave: nunca expongas tu JWT_SECRET ni lo comprimas en el repositorio.
Consejo avanzado: para alta escala, cambia a RS256 con claves privadas protegidas en un HSM o KMS y distribuye la clave pública a tus microservicios para validar tokens sin compartir secretos.
¿Quieres comentar?
Inicia sesión con Telegram para participar en la conversación