JWT en PHP: autenticación sin sesiones con tokens y firebase/php-jwt

Un JWT (JSON Web Token) es un token compacto y autocontenido que lleva información del usuario codificada y firmada. A diferencia de las sesiones del servidor (que guardan el estado en memoria o base de datos), con JWT el estado viaja en el propio token: el servidor solo necesita verificar la firma para saber quién es el usuario. Esto hace que las APIs JWT sean fácilmente escalables horizontalmente.

Estructura de un JWT

Un JWT tiene tres partes separadas por puntos: header.payload.signature, las dos primeras en Base64URL.

# Header (algoritmo y tipo)
{"alg": "HS256", "typ": "JWT"}

# Payload (claims: datos del usuario y metadatos)
{
  "sub": "123",
  "nombre": "Ana García",
  "rol": "admin",
  "iat": 1750000000,
  "exp": 1750003600
}

# Signature: HMACSHA256(base64(header) + '.' + base64(payload), secreto)

Instalación de firebase/php-jwt

composer require firebase/php-jwt

Generar un token

<?php
use FirebaseJWTJWT;
use FirebaseJWTKey;

$secreto  = getenv('JWT_SECRET'); // Nunca en el código fuente
$algoritmo = 'HS256';

function generarToken(array $payload): string {
    global $secreto, $algoritmo;

    $ahora = time();
    $claims = array_merge($payload, [
        'iat' => $ahora,           // issued at
        'nbf' => $ahora,           // not before
        'exp' => $ahora + 3600,    // expira en 1 hora
    ]);

    return JWT::encode($claims, $secreto, $algoritmo);
}

// Login exitoso ? generar token
$token = generarToken([
    'sub'    => 42,            // ID del usuario
    'nombre' => 'Ana García',
    'rol'    => 'admin',
]);

echo $token;
// eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9...

Verificar y decodificar un token

<?php
function verificarToken(string $token): object {
    global $secreto, $algoritmo;

    try {
        return JWT::decode($token, new Key($secreto, $algoritmo));
    } catch (FirebaseJWTExpiredException $e) {
        throw new RuntimeException('Token expirado', 401);
    } catch (FirebaseJWTSignatureInvalidException $e) {
        throw new RuntimeException('Firma inválida', 401);
    } catch (UnexpectedValueException $e) {
        throw new RuntimeException('Token inválido: ' . $e->getMessage(), 401);
    }
}

try {
    $datos = verificarToken($token);
    echo $datos->sub;     // 42
    echo $datos->nombre;  // Ana García
    echo $datos->rol;     // admin
} catch (RuntimeException $e) {
    http_response_code($e->getCode());
    echo json_encode(['error' => $e->getMessage()]);
    exit;
}

Leer el token del header Authorization

<?php
function obtenerTokenDeHeader(): string {
    $cabecera = $_SERVER['HTTP_AUTHORIZATION']
             ?? $_SERVER['REDIRECT_HTTP_AUTHORIZATION']
             ?? '';

    if (preg_match('/Bearers+(.+)/i', $cabecera, $m)) {
        return $m[1];
    }

    throw new RuntimeException('No se encontró token Bearer', 401);
}

// En tu middleware de autenticación
try {
    $token  = obtenerTokenDeHeader();
    $claims = verificarToken($token);
    // $claims->sub contiene el ID del usuario autenticado
} catch (RuntimeException $e) {
    http_response_code($e->getCode());
    echo json_encode(['error' => $e->getMessage()]);
    exit;
}

Refresh tokens: mantener la sesión sin pedir contraseña

Los access tokens tienen vida corta (15 min–1 h). Para no pedir al usuario que se loguee cada hora, se usa un refresh token de vida larga guardado en la BD o en una cookie HttpOnly:

<?php
function login(string $email, string $password): array {
    // Verificar credenciales en la BD...
    $usuario = buscarUsuario($email, $password);
    if (!$usuario) throw new RuntimeException('Credenciales incorrectas', 401);

    $accessToken = generarToken(['sub' => $usuario['id'], 'rol' => $usuario['rol']]);

    // Refresh token: más largo, guardado en BD con hash
    $refreshToken = bin2hex(random_bytes(64));
    guardarRefreshToken($usuario['id'], hash('sha256', $refreshToken), time() + 86400 * 30);

    return [
        'access_token'  => $accessToken,
        'refresh_token' => $refreshToken,
        'expires_in'    => 3600,
    ];
}

function renovar(string $refreshToken): array {
    $hash    = hash('sha256', $refreshToken);
    $usuario = buscarPorRefreshToken($hash);

    if (!$usuario || $usuario['expires_at'] < time()) {
        throw new RuntimeException('Refresh token inválido o expirado', 401);
    }

    // Rotación: invalidar el anterior y emitir uno nuevo
    invalidarRefreshToken($hash);
    return login($usuario['email'], null); // login sin contraseña tras validar refresh
}

Clave secreta en variables de entorno

# .env
JWT_SECRET=una_clave_muy_larga_aleatoria_minimo_32_caracteres

# Generar una clave segura:
# php -r "echo bin2hex(random_bytes(32));"

Nunca hardcodees el secreto. Una clave comprometida permite a cualquiera generar tokens válidos para cualquier usuario.

Cuándo NO usar JWT

  • Si necesitas invalidar tokens antes de que expiren (logout inmediato, cambio de contraseña), necesitas una lista negra en BD/Redis, lo que anula parte de la ventaja «stateless» de JWT.
  • Para sesiones web tradicionales con un único servidor, las sesiones de PHP son más simples y seguras.
  • JWT brilla en APIs consumidas por clientes móviles, SPAs o microservicios.

COMPARTE ESTE ARTÍCULO

COMPARTIR EN FACEBOOK
COMPARTIR EN TWITTER
COMPARTIR EN LINKEDIN
COMPARTIR EN WHATSAPP