Crear una API RESTful en PHP con autenticación JWT y SQLite
Proyecto práctico: una API mínima para usuarios y notas (CRUD básico), autenticada con JWT. Ideal como base para apps móviles o SPA. Enfocado en buenas prácticas: PDO, prepared statements, password_hash y manejo sencillo de tokens.
Requisitos previos
- PHP 8.0+ con extensiones
pdoypdo_sqlite. - Composer instalado.
- Conocimientos básicos de HTTP/REST y PHP.
Estructura de carpetas
project/ ├─ composer.json ├─ migrations/ │ └─ init.sql ├─ public/ │ └─ index.php (router único) └─ src/ ├─ config.php ├─ Database.php ├─ Auth.php ├─ UserController.php └─ NoteController.php
Instalación (rápida)
cd project
composer require firebase/php-jwt
# Crear base de datos:
php -r "mkdir('data');"
sqlite3 data/app.db < migrations/init.sql
# Levanta con PHP built-in (solo para desarrollo):
php -S localhost:8000 -t public
Archivo de migraciones: migrations/init.sql
-- Usuarios y notas mínimas CREATE TABLE IF NOT EXISTS users ( id INTEGER PRIMARY KEY AUTOINCREMENT, email TEXT UNIQUE NOT NULL, password TEXT NOT NULL, created_at DATETIME DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE IF NOT EXISTS notes ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL, title TEXT NOT NULL, body TEXT, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE );
Composer (solo referencia) - composer.json
{
"require": {
"firebase/php-jwt": "^6.0"
},
"autoload": {
"psr-4": {
"App\\": "src/"
}
}
}
Después de crear composer.json ejecuta composer install. También puedes ejecutar composer require firebase/php-jwt directamente.
Configuración: src/config.php
Clase Database: src/Database.php
setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); $pdo->setAttribute(PDO::ATTR_DEFAULT_FETCH_MODE, PDO::FETCH_ASSOC); // Foreign keys ON for SQLite $pdo->exec('PRAGMA foreign_keys = ON'); self::$pdo = $pdo; } return self::$pdo; } }Autenticación y helpers JWT: src/Auth.php
$now, 'exp' => $now + Config::JWT_EXP ], $payload); return JWT::encode($data, Config::JWT_SECRET, 'HS256'); } public static function validateToken(?string $bearer): ?array { if (!$bearer) return null; if (preg_match('/^Bearer\s+(.*)$/i', $bearer, $m)) { $jwt = $m[1]; try { $decoded = JWT::decode($jwt, new Key(Config::JWT_SECRET, 'HS256')); return (array)$decoded; } catch (\Exception $e) { return null; } } return null; } }Controlador de usuarios: src/UserController.php
db = Database::get(); } public function register(array $data) { if (empty($data['email']) || empty($data['password'])) { http_response_code(422); return ['error' => 'email y password son obligatorios']; } $email = strtolower(trim($data['email'])); $hash = password_hash($data['password'], PASSWORD_DEFAULT); $stmt = $this->db->prepare('INSERT INTO users (email, password) VALUES (:email, :password)'); try { $stmt->execute([':email' => $email, ':password' => $hash]); $id = $this->db->lastInsertId(); http_response_code(201); return ['id' => $id, 'email' => $email]; } catch (\PDOException $e) { http_response_code(409); return ['error' => 'El email ya está registrado']; } } public function login(array $data) { if (empty($data['email']) || empty($data['password'])) { http_response_code(422); return ['error' => 'email y password son obligatorios']; } $stmt = $this->db->prepare('SELECT id, email, password FROM users WHERE email = :email'); $stmt->execute([':email' => strtolower(trim($data['email']))]); $user = $stmt->fetch(); if (!$user || !password_verify($data['password'], $user['password'])) { http_response_code(401); return ['error' => 'Credenciales inválidas']; } $token = Auth::generateToken(['sub' => $user['id'], 'email' => $user['email']]); return ['token' => $token]; } }Controlador de notas (protegido): src/NoteController.php
db = Database::get(); } public function list(int $userId) { $stmt = $this->db->prepare('SELECT id, title, body, created_at FROM notes WHERE user_id = :uid ORDER BY created_at DESC'); $stmt->execute([':uid' => $userId]); return $stmt->fetchAll(); } public function create(int $userId, array $data) { if (empty($data['title'])) { http_response_code(422); return ['error' => 'title es obligatorio']; } $stmt = $this->db->prepare('INSERT INTO notes (user_id, title, body) VALUES (:uid, :title, :body)'); $stmt->execute([':uid' => $userId, ':title' => $data['title'], ':body' => ($data['body'] ?? null)]); http_response_code(201); return ['id' => $this->db->lastInsertId(), 'title' => $data['title']]; } }Router único (public/index.php)
register($input)); exit; } if ($uri === '/login' && $method === 'POST') { $ctrl = new UserController(); echo json_encode($ctrl->login($input)); exit; } // Protected endpoints $authHeader = $_SERVER['HTTP_AUTHORIZATION'] ?? null; $payload = Auth::validateToken($authHeader); if (!$payload) { http_response_code(401); echo json_encode(['error' => 'no autorizado']); exit; } $userId = (int)($payload['sub'] ?? 0); if ($uri === '/notes' && $method === 'GET') { $ctrl = new NoteController(); echo json_encode($ctrl->list($userId)); exit; } if ($uri === '/notes' && $method === 'POST') { $ctrl = new NoteController(); echo json_encode($ctrl->create($userId, $input)); exit; } // Fallback http_response_code(404); echo json_encode(['error' => 'ruta no encontrada']); } catch (\Throwable $e) { http_response_code(500); echo json_encode(['error' => 'error interno', 'msg' => $e->getMessage()]); }Por qué estas decisiones (no solo cómo)
- PDO con prepared statements: evita SQL injection y es consistente entre motores.
- SQLite: fácil de configurar para demos y pruebas; cambia a MySQL/Postgres en producción sin mayor refactor.
- password_hash / password_verify: hashing seguro con sal automática; nunca almacenes contraseñas en claro.
- JWT: tokens stateless adecuados para APIs; incluyen fecha de expiración (exp). No son un reemplazo para el almacenamiento seguro de sesiones si requieres revocación inmediata.
- Router mínimo: sin framework para enseñar la lógica limpia; en apps reales considera Slim/Laravel/Symfony para más funcionalidades.
Pruebas rápidas (curl)
# Registrar
curl -X POST http://localhost:8000/register -H 'Content-Type: application/json' -d '{"email":"user@example.com","password":"secret"}'
# Login
curl -X POST http://localhost:8000/login -H 'Content-Type: application/json' -d '{"email":"user@example.com","password":"secret"}'
# Usando token (reemplaza TOKEN)
curl -X GET http://localhost:8000/notes -H 'Authorization: Bearer TOKEN'
# Crear nota
curl -X POST http://localhost:8000/notes -H 'Authorization: Bearer TOKEN' -H 'Content-Type: application/json' -d '{"title":"Primera nota","body":"Contenido"}'
Seguridad y producción
- Usa HTTPS siempre. Nunca envíes tokens por HTTP plano.
- Guarda secretos (JWT_SECRET) en variables de entorno o un vault; no en el repo.
- Considera refresh tokens y revocación: JWT self-contained dificulta revocación inmediata.
- Aplica rate-limiting y protección contra fuerza bruta en endpoints de login.
- Valida y sanitiza entradas adicionales si permites HTML o campos complejos.
Siguiente paso natural: integrar este backend con una SPA (React/Vue) y añadir tests automatizados (PHPUnit) para endpoints críticos. Un consejo avanzado: implementa una lista de tokens revocados en Redis con expiración para poder invalidar tokens sin cambiar la lógica de JWT.
¿Quieres comentar?
Inicia sesión con Telegram para participar en la conversación