API REST en PHP con autenticación JWT y PDO (registro, login y perfil)
Proyecto práctico: crea una pequeña API segura en PHP que permita registrar usuarios, iniciar sesión (devuelve un JWT) y acceder a una ruta protegida (/profile). Usaremos PDO para consultas seguras, password_hash/password_verify y JWT para tokens.
Requisitos previos
- PHP 8.0+
- Composer
- MySQL o MariaDB
- Servidor web (Apache/Nginx) o built-in server de PHP
Dependencias (Composer)
Usaremos:
vlucas/phpdotenvpara variables de entornofirebase/php-jwtpara generar/validar JWT
{
"require": {
"vlucas/phpdotenv": "^5.5",
"firebase/php-jwt": "^6.0"
},
"autoload": {
"psr-4": { "App\\": "src/" }
}
}
Estructura de carpetas
project-root/
├─ composer.json
├─ .env.example
├─ migrations.sql
├─ public/
│ ├─ index.php # punto de entrada (router simple)
│ └─ .htaccess # opcional para Apache
├─ src/
│ ├─ Database.php
│ ├─ Models/
│ │ └─ User.php
│ └─ Controllers/
│ └─ AuthController.php
└─ vendor/
SQL: tabla users
Archivo migrations.sql:
CREATE TABLE users (
id INT AUTO_INCREMENT PRIMARY KEY,
email VARCHAR(255) NOT NULL UNIQUE,
password VARCHAR(255) NOT NULL,
name VARCHAR(100) NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
.env de ejemplo
DB_HOST=127.0.0.1
DB_PORT=3306
DB_NAME=api_jwt_db
DB_USER=root
DB_PASS=secret
JWT_SECRET=tu_secreto_muy_seguro_aqui
JWT_ISSUER=tu_dominio.local
JWT_EXPIRE=3600
Implementación: archivos principales
1) src/Database.php — Singleton PDO con variables de entorno.
<?php
namespace App;
use Dotenv\Dotenv;
use PDO;
use PDOException;
class Database
{
private static ?PDO $instance = null;
public static function getConnection(): PDO
{
if (self::$instance === null) {
$dotenv = Dotenv::createImmutable(__DIR__ . '/../');
$dotenv->safeLoad();
$host = $_ENV['DB_HOST'] ?? '127.0.0.1';
$port = $_ENV['DB_PORT'] ?? '3306';
$db = $_ENV['DB_NAME'] ?? 'test';
$user = $_ENV['DB_USER'] ?? 'root';
$pass = $_ENV['DB_PASS'] ?? '';
$dsn = "mysql:host={$host};port={$port};dbname={$db};charset=utf8mb4";
try {
$pdo = new PDO($dsn, $user, $pass, [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
PDO::ATTR_EMULATE_PREPARES => false,
]);
self::$instance = $pdo;
} catch (PDOException $e) {
http_response_code(500);
echo json_encode(['error' => 'DB connection failed']);
exit;
}
}
return self::$instance;
}
}
Por qué: usar un singleton evita múltiples conexiones. PDO con ERRMODE_EXCEPTION y EMULATE_PREPARES=false mejora seguridad y comportamiento.
2) src/Models/User.php — operaciones sobre usuarios.
<?php
namespace App\Models;
use App\Database;
use PDO;
class User
{
private PDO $db;
public function __construct()
{
$this->db = Database::getConnection();
}
public function create(string $email, string $password, ?string $name = null): ?int
{
$sql = 'INSERT INTO users (email, password, name) VALUES (:email, :password, :name)';
$stmt = $this->db->prepare($sql);
$stmt->execute([
':email' => $email,
':password' => $password,
':name' => $name,
]);
return (int)$this->db->lastInsertId();
}
public function findByEmail(string $email): ?array
{
$sql = 'SELECT id, email, password, name, created_at FROM users WHERE email = :email LIMIT 1';
$stmt = $this->db->prepare($sql);
$stmt->execute([':email' => $email]);
$user = $stmt->fetch();
return $user ?: null;
}
public function findById(int $id): ?array
{
$sql = 'SELECT id, email, name, created_at FROM users WHERE id = :id LIMIT 1';
$stmt = $this->db->prepare($sql);
$stmt->execute([':id' => $id]);
$user = $stmt->fetch();
return $user ?: null;
}
}
Por qué: separar modelo permite pruebas y reutilización. El método create recibe la contraseña ya hasheada.
3) src/Controllers/AuthController.php — lógica de registro, login y verificación del token.
<?php
namespace App\Controllers;
use App\Models\User;
use Firebase\JWT\JWT;
use Firebase\JWT\Key;
class AuthController
{
private User $userModel;
public function __construct()
{
$this->userModel = new User();
}
public function register(array $data)
{
$email = filter_var($data['email'] ?? '', FILTER_VALIDATE_EMAIL);
$password = $data['password'] ?? '';
$name = isset($data['name']) ? trim($data['name']) : null;
if (!$email || strlen($password) < 8) {
http_response_code(422);
echo json_encode(['error' => 'Invalid input. Email must be valid and password >= 8 chars.']);
return;
}
if ($this->userModel->findByEmail($email)) {
http_response_code(409);
echo json_encode(['error' => 'User already exists']);
return;
}
$passwordHash = password_hash($password, PASSWORD_DEFAULT);
$id = $this->userModel->create($email, $passwordHash, $name);
http_response_code(201);
echo json_encode(['id' => $id, 'email' => $email]);
}
public function login(array $data)
{
$email = filter_var($data['email'] ?? '', FILTER_VALIDATE_EMAIL);
$password = $data['password'] ?? '';
if (!$email || !$password) {
http_response_code(422);
echo json_encode(['error' => 'Email and password required']);
return;
}
$user = $this->userModel->findByEmail($email);
if (!$user || !password_verify($password, $user['password'])) {
http_response_code(401);
echo json_encode(['error' => 'Invalid credentials']);
return;
}
$now = time();
$expire = (int)($_ENV['JWT_EXPIRE'] ?? 3600);
$payload = [
'iat' => $now,
'exp' => $now + $expire,
'iss' => ($_ENV['JWT_ISSUER'] ?? 'local'),
'sub' => (string)$user['id'],
'email' => $user['email']
];
$jwt = JWT::encode($payload, $_ENV['JWT_SECRET'], 'HS256');
echo json_encode(['token' => $jwt, 'expires_in' => $expire]);
}
public function profile(array $headers)
{
$authHeader = $headers['Authorization'] ?? $headers['authorization'] ?? '';
if (!$authHeader || !str_starts_with($authHeader, 'Bearer ')) {
http_response_code(401);
echo json_encode(['error' => 'Missing token']);
return;
}
$token = trim(substr($authHeader, 7));
try {
$decoded = JWT::decode($token, new Key($_ENV['JWT_SECRET'], 'HS256'));
$userId = (int)$decoded->sub;
$user = $this->userModel->findById($userId);
if (!$user) {
http_response_code(404);
echo json_encode(['error' => 'User not found']);
return;
}
echo json_encode(['user' => $user]);
} catch (\Exception $e) {
http_response_code(401);
echo json_encode(['error' => 'Invalid or expired token']);
}
}
}
Por qué: el controlador centraliza validación y respuestas. Usamos password_hash y password_verify. El token contiene sub (user id) y expiración.
4) public/index.php — router simple y punto de entrada.
<?php
require __DIR__ . '/../vendor/autoload.php';
use App\Controllers\AuthController;
use Dotenv\Dotenv;
header('Content-Type: application/json; charset=utf-8');
// CORS básico, ajustar en producción
header('Access-Control-Allow-Origin: *');
header('Access-Control-Allow-Methods: GET, POST, OPTIONS');
header('Access-Control-Allow-Headers: Content-Type, Authorization');
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') {
http_response_code(204);
exit;
}
$dotenv = Dotenv::createImmutable(__DIR__ . '/../');
$dotenv->safeLoad();
$path = parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH);
$method = $_SERVER['REQUEST_METHOD'];
$auth = new AuthController();
$input = json_decode(file_get_contents('php://input'), true) ?: [];
switch (true) {
case $path === '/register' && $method === 'POST':
$auth->register($input);
break;
case $path === '/login' && $method === 'POST':
$auth->login($input);
break;
case $path === '/profile' && $method === 'GET':
// getallheaders() may not be available en algunos SAPIs; usar alternativa
$headers = function_exists('getallheaders') ? getallheaders() : [];
$auth->profile($headers);
break;
default:
http_response_code(404);
echo json_encode(['error' => 'Not found']);
}
Por qué: router minimalista para mantener el ejemplo enfocado. En producción usar un micro-framework o un router robusto.
5) public/.htaccess (opcional para Apache)
RewriteEngine On
RewriteCond %{REQUEST_FILENAME} !-f
RewriteRule ^ index.php [QSA,L]
Instalación y ejecución
- Clona/crea el proyecto y coloca los archivos según la estructura.
- Ejecuta
composer install. - Crea la base de datos y ejecuta
migrations.sql. - Copia
.env.examplea.envy ajusta valores. - Inicia el servidor: por ejemplo
php -S localhost:8080 -t public.
Pruebas (curl)
Registro:
curl -X POST http://localhost:8080/register \
-H "Content-Type: application/json" \
-d '{"email":"dev@ejemplo.local","password":"Password123","name":"Dev"}'
Login:
curl -X POST http://localhost:8080/login \
-H "Content-Type: application/json" \
-d '{"email":"dev@ejemplo.local","password":"Password123"}'
# Respuesta: {"token":"...","expires_in":3600}
Acceder a /profile usando el token:
curl http://localhost:8080/profile \
-H "Authorization: Bearer TU_TOKEN_AQUI"
Explicación de decisiones clave (el porqué)
- PDO con consultas preparadas: previene inyección SQL y mejora la portabilidad entre DBMS.
- password_hash / password_verify: uso del algoritmo seguro y adaptativo (bcrypt/argon2 según plataforma).
- JWT: permite autenticación stateless para APIs. Guardamos solo el id en el token (
sub) para evitar exponer datos sensibles. - Dotenv: variables sensibles (DB, JWT secret) fuera del repositorio.
- Token expiración: fundamental para limitar ventana de abuso; usar refresh tokens para sesiones largas.
- Router simple: suficiente para ejemplo; en producción sustituir por FastRoute, Slim, Lumen o un framework.
Mejoras recomendadas y riesgos a considerar
- Usa HTTPS obligatorio en producción: los tokens y credenciales viajan en claro sin TLS.
- Implementa rate limiting en endpoints sensibles (/login, /register).
- Considera listas de revocación o tokens de refresco si necesitas invalidar tokens antes de que expiren.
- Almacena el secreto JWT de forma segura (KMS/secret manager) y rota periódicamente.
- Valida esquema de entrada más estrictamente (ej. usando JSON Schema o validadores).
- Protege contra fuerza bruta y enumeración de usuarios (respuestas genéricas para credenciales inválidas).
Siguiente paso práctico: integra un endpoint para refrescar tokens usando refresh tokens almacenados en la base de datos (con expiración y revocación). Como advertencia de seguridad: jamás almacenes el JWT en localStorage sin considerar XSS; prefiere HttpOnly cookies si la API es consumida por un navegador y necesitas protección contra XSS.
¿Quieres comentar?
Inicia sesión con Telegram para participar en la conversación