API de autenticación segura en PHP (JWT + Refresh Tokens) sin frameworks

php API de autenticación segura en PHP (JWT + Refresh Tokens) sin frameworks

API de autenticación segura en PHP (JWT + Refresh Tokens) sin frameworks

Este tutorial te guía para construir una API REST mínima en PHP que implementa autenticación basada en JWT para acceso corto y refresh tokens para rotación segura. Usaremos PDO, password_hash, firebase/php-jwt y vlucas/phpdotenv. El objetivo es un sistema simple, seguro y fácil de entender.

Requisitos previos

  • PHP 8.0+ con ext-pdo y ext-openssl
  • Composer
  • MySQL/MariaDB
  • Conocimientos básicos de HTTP y JWT

Estructura de carpetas

auth-php-jwt/
├── composer.json
├── .env
├── migrations.sql
├── public/
│   └── index.php
├── src/
│   ├── Config.php
│   ├── Database.php
│   ├── UserModel.php
│   └── Auth.php
└── vendor/

Instalación inicial

# Inicializar composer
composer init --name="tu/namespace" --require="firebase/php-jwt:^6.0" --require="vlucas/phpdotenv:^5.0" -n
composer install

O usa este composer.json:

{
  "require": {
    "firebase/php-jwt": "^6.0",
    "vlucas/phpdotenv": "^5.0"
  },
  "autoload": {
    "psr-4": {
      "App\\": "src/"
    }
  }
}

Luego ejecuta composer dump-autoload.

.env (muestra)

APP_ENV=development
DB_DSN=mysql:host=127.0.0.1;dbname=auth_db;charset=utf8mb4
DB_USER=root
DB_PASS=secret
JWT_SECRET=una_clave_muy_larga_y_segura
ACCESS_TOKEN_EXP=900   # 15 minutos
REFRESH_TOKEN_EXP=1209600  # 14 días (en segundos)

Migraciones SQL

-- migrations.sql
CREATE TABLE users (
  id INT AUTO_INCREMENT PRIMARY KEY,
  email VARCHAR(255) NOT NULL UNIQUE,
  password VARCHAR(255) NOT NULL,
  created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

CREATE TABLE refresh_tokens (
  id INT AUTO_INCREMENT PRIMARY KEY,
  user_id INT NOT NULL,
  token_hash VARCHAR(255) NOT NULL,
  expires_at DATETIME NOT NULL,
  created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
  revoked TINYINT(1) DEFAULT 0,
  FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);

Archivo: src/Config.php

load();
        self::$env = $_ENV;
    }

    public static function get(string $key, $default = null)
    {
        return self::$env[$key] ?? $default;
    }
}

Archivo: src/Database.php

 PDO::ERRMODE_EXCEPTION,
                PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
            ]);
        } catch (PDOException $e) {
            http_response_code(500);
            echo json_encode(['error' => 'DB connection failed']);
            exit;
        }

        return self::$pdo;
    }
}

Archivo: src/UserModel.php

db = Database::get();
    }

    public function create(string $email, string $password): int
    {
        $hash = password_hash($password, PASSWORD_ARGON2ID);
        $stmt = $this->db->prepare('INSERT INTO users (email, password) VALUES (:email, :password)');
        $stmt->execute(['email' => $email, 'password' => $hash]);
        return (int)$this->db->lastInsertId();
    }

    public function findByEmail(string $email): ?array
    {
        $stmt = $this->db->prepare('SELECT * FROM users WHERE email = :email');
        $stmt->execute(['email' => $email]);
        $row = $stmt->fetch();
        return $row ?: null;
    }

    public function findById(int $id): ?array
    {
        $stmt = $this->db->prepare('SELECT id, email, created_at FROM users WHERE id = :id');
        $stmt->execute(['id' => $id]);
        $row = $stmt->fetch();
        return $row ?: null;
    }

    public function storeRefreshToken(int $userId, string $tokenHash, string $expiresAt): void
    {
        $stmt = $this->db->prepare('INSERT INTO refresh_tokens (user_id, token_hash, expires_at) VALUES (:user_id, :token_hash, :expires_at)');
        $stmt->execute(['user_id' => $userId, 'token_hash' => $tokenHash, 'expires_at' => $expiresAt]);
    }

    public function revokeRefreshToken(int $userId, string $tokenHash): void
    {
        $stmt = $this->db->prepare('UPDATE refresh_tokens SET revoked = 1 WHERE user_id = :user_id AND token_hash = :token_hash');
        $stmt->execute(['user_id' => $userId, 'token_hash' => $tokenHash]);
    }

    public function findValidRefreshToken(int $userId, string $token): ?array
    {
        $stmt = $this->db->prepare('SELECT * FROM refresh_tokens WHERE user_id = :user_id AND revoked = 0 AND expires_at > NOW()');
        $stmt->execute(['user_id' => $userId]);
        $rows = $stmt->fetchAll();
        foreach ($rows as $row) {
            if (password_verify($token, $row['token_hash'])) {
                return $row;
            }
        }
        return null;
    }
}

Archivo: src/Auth.php

 $now,
            'exp' => $now + (int)Config::get('ACCESS_TOKEN_EXP', 900),
            'sub' => $user['id'],
            'email' => $user['email']
        ];
        return JWT::encode($payload, self::secret(), 'HS256');
    }

    public static function decodeAccessToken(string $jwt): ?array
    {
        try {
            $decoded = JWT::decode($jwt, new Key(self::secret(), 'HS256'));
            return (array)$decoded;
        } catch (\Throwable $e) {
            return null;
        }
    }

    public static function generateRefreshToken(): string
    {
        return bin2hex(random_bytes(64));
    }

    public static function hashRefreshToken(string $token): string
    {
        return password_hash($token, PASSWORD_ARGON2ID);
    }
}

Archivo público: public/index.php

 'Invalid input'], 422);
    }
    if ($userModel->findByEmail($email)) {
        respond(['error' => 'Email already exists'], 409);
    }
    $userId = $userModel->create($email, $password);
    respond(['message' => 'User created', 'user_id' => $userId], 201);
}

if ($path === '/login' && $method === 'POST') {
    $email = $input['email'] ?? '';
    $password = $input['password'] ?? '';
    $user = $userModel->findByEmail($email);
    if (!$user || !password_verify($password, $user['password'])) {
        respond(['error' => 'Invalid credentials'], 401);
    }

    $accessToken = Auth::generateAccessToken($user);
    $refreshToken = Auth::generateRefreshToken();
    $refreshHash = Auth::hashRefreshToken($refreshToken);

    $expiresAt = date('Y-m-d H:i:s', time() + (int)Config::get('REFRESH_TOKEN_EXP', 1209600));
    $userModel->storeRefreshToken((int)$user['id'], $refreshHash, $expiresAt);

    respond(['access_token' => $accessToken, 'refresh_token' => $refreshToken]);
}

if ($path === '/refresh' && $method === 'POST') {
    $refreshToken = $input['refresh_token'] ?? '';
    $accessToken = $input['access_token'] ?? null; // optional for rotation

    if (!$refreshToken) {
        respond(['error' => 'Missing refresh token'], 400);
    }

    // If access token present, try to decode to get user id (not required)
    $userId = null;
    if ($accessToken) {
        $decoded = Auth::decodeAccessToken($accessToken);
        $userId = $decoded['sub'] ?? null;
    }

    // If we don't have userId, we need to search among refresh tokens: but refresh tokens are per user.
    // For simplicity we will require a 'user_id' in body if access_token absent.
    if (!$userId) {
        $userId = $input['user_id'] ?? null;
    }

    if (!$userId) {
        respond(['error' => 'Missing user identifier'], 400);
    }

    $valid = $userModel->findValidRefreshToken((int)$userId, $refreshToken);
    if (!$valid) {
        respond(['error' => 'Invalid or expired refresh token'], 401);
    }

    // Rotate: revoke old token and issue new refresh + access
    $userModel->revokeRefreshToken((int)$userId, $valid['token_hash']);

    $newRefresh = Auth::generateRefreshToken();
    $newHash = Auth::hashRefreshToken($newRefresh);
    $expiresAt = date('Y-m-d H:i:s', time() + (int)Config::get('REFRESH_TOKEN_EXP', 1209600));
    $userModel->storeRefreshToken((int)$userId, $newHash, $expiresAt);

    $user = $userModel->findById((int)$userId);
    $newAccess = Auth::generateAccessToken($user);

    respond(['access_token' => $newAccess, 'refresh_token' => $newRefresh]);
}

if ($path === '/logout' && $method === 'POST') {
    $refreshToken = $input['refresh_token'] ?? '';
    $userId = $input['user_id'] ?? null;
    if (!$refreshToken || !$userId) {
        respond(['error' => 'Missing parameters'], 400);
    }
    // Revoke any matching token for user
    $valid = $userModel->findValidRefreshToken((int)$userId, $refreshToken);
    if ($valid) {
        $userModel->revokeRefreshToken((int)$userId, $valid['token_hash']);
    }
    respond(['message' => 'Logged out']);
}

if ($path === '/me' && $method === 'GET') {
    $authHeader = $_SERVER['HTTP_AUTHORIZATION'] ?? '';
    if (!$authHeader || !str_starts_with($authHeader, 'Bearer ')) {
        respond(['error' => 'Missing token'], 401);
    }
    $token = trim(substr($authHeader, 7));
    $decoded = Auth::decodeAccessToken($token);
    if (!$decoded) {
        respond(['error' => 'Invalid token'], 401);
    }
    $user = $userModel->findById((int)$decoded['sub']);
    if (!$user) respond(['error' => 'User not found'], 404);
    respond(['user' => $user]);
}

// default
respond(['message' => 'Not found'], 404);

Por qué este diseño

  • JWT como access token: rápido y sin consultas a DB para validar (hasta expiración).
  • Refresh tokens: almacenados en BD (hashados) para poder revocar/rotar. No guardes el refresh token en texto plano.
  • Rotación: cuando se usa un refresh token se revoca y se emite uno nuevo; esto limita el daño si un token se filtra.
  • password_hash + Argon2id: proteges contraseñas y tokens almacenados.
  • Separación de responsabilidades: UserModel para BD, Auth para tokens.

Consideraciones de seguridad

  • Almacena JWT_SECRET de forma segura (no en repositorio). Usa un secret fuerte y rotalo si es necesario.
  • Envía tokens en cookies HttpOnly + Secure si la API va a ser consumida por navegadores; para APIs públicas considera CORS y almacenamiento en memoria o secure storage.
  • Limita la vida del access token (p. ej. 5-15 minutos) y del refresh token, y registra actividad para detectar abuso.
  • Protege endpoints críticos con rate limiting.
  • Usa TLS siempre.

Puedes probar las rutas con curl o Postman: registro en /register, login en /login (obtendrás access + refresh token), acceder a /me con Authorization: Bearer <access_token>, refrescar con /refresh.

Siguiente paso: añade un sistema de invalidación global para JWTs (p. ej. un campo token_version en users y colocarlo dentro del JWT; al rotar o forzar logout incrementas la versión y validas que coincida) o implementa almacenamiento de tokens en Redis para revocación rápida y expiración automática.

Advertencia de seguridad: nunca guardes refresh tokens en localStorage para aplicaciones web; usa cookies HttpOnly y con SameSite apropiado para mitigar XSS/CSRF según tu flujo.

Comentarios
¿Quieres comentar?

Inicia sesión con Telegram para participar en la conversación


Comentarios (0)

Aún no hay comentarios. ¡Sé el primero en comentar!

Iniciar Sesión