php

JWT авторизация на PHP с нуля: создание, валидация и refresh-токены

Разберём, как написать собственную JWT-авторизацию на чистом PHP — без Laravel Passport и без сторонних пакетов вроде firebase/php-jwt. К концу статьи у вас будет рабочий класс на 80 строк, который умеет создавать access- и refresh-токены, проверять подпись, валидировать срок жизни и давать понятные ошибки. Пригодится для SPA, мобильных приложений и REST API на Symfony, Slim или собственном микро-фреймворке.

Что такое JWT и из чего он состоит

JWT (JSON Web Token) — это строка из трёх частей, разделённых точками: header.payload.signature. Каждая часть — это JSON, закодированный в base64url. Сервер проверяет подпись секретным ключом и доверяет содержимому без обращения к базе данных.

Структура JWT-токена: header, payload, signature
Три части JWT: заголовок с алгоритмом, полезная нагрузка и HMAC-подпись

Главное отличие от классической сессии — состояние не хранится на сервере. Это плюс для горизонтального масштабирования (любой инстанс примет токен) и минус для отзыва: чтобы выкинуть пользователя до истечения exp, нужен отдельный механизм — чёрный список или короткий TTL + refresh-токены.

Кодирование base64url и подпись HMAC-SHA256

Стандарт JWT использует base64url — тот же base64, но с заменой символов +/ на -_ и без хвостовых =. PHP-функции base64_encode/decode работают с обычным base64, поэтому нужны два хелпера:

<?php
// Кодирование в base64url (без padding, URL-safe символы)
function base64url_encode(string $data): string {
    return rtrim(strtr(base64_encode($data), '+/', '-_'), '=');
}

// Декодирование с восстановлением padding
function base64url_decode(string $data): string {
    $remainder = strlen($data) % 4;
    if ($remainder) {
        $data .= str_repeat('=', 4 - $remainder);
    }
    return base64_decode(strtr($data, '-_', '+/'));
}

Подпись считается через hash_hmac с алгоритмом sha256. Важный нюанс — в функцию передаём третьим параметром true, чтобы получить сырые байты, а не hex-строку. Затем эти байты тоже кодируются в base64url.


Класс JwtService: создание и проверка токенов

Соберём всё в один класс. Он хранит секрет, выдаёт токен по полезной нагрузке и проверяет входящий токен с разбором всех ошибок: истёкший срок, неверная подпись, повреждённая структура.

<?php
declare(strict_types=1);

class JwtService
{
    public function __construct(
        private readonly string $secret,
        private readonly int $accessTtl = 900,    // 15 минут
        private readonly int $refreshTtl = 604800 // 7 дней
    ) {
        if (strlen($secret) < 32) {
            throw new \InvalidArgumentException('Секрет должен быть не короче 32 символов');
        }
    }

    public function issueAccess(array $claims): string
    {
        return $this->encode($claims + [
            'iat' => time(),
            'exp' => time() + $this->accessTtl,
            'typ' => 'access',
        ]);
    }

    public function issueRefresh(int $userId): string
    {
        return $this->encode([
            'sub' => $userId,
            'iat' => time(),
            'exp' => time() + $this->refreshTtl,
            'typ' => 'refresh',
            'jti' => bin2hex(random_bytes(16)), // уникальный ID для отзыва
        ]);
    }

    private function encode(array $payload): string
    {
        $header = ['alg' => 'HS256', 'typ' => 'JWT'];
        $h = base64url_encode(json_encode($header, JSON_UNESCAPED_UNICODE));
        $p = base64url_encode(json_encode($payload, JSON_UNESCAPED_UNICODE));
        $sig = base64url_encode(
            hash_hmac('sha256', "$h.$p", $this->secret, true)
        );
        return "$h.$p.$sig";
    }

    public function decode(string $token): array
    {
        $parts = explode('.', $token);
        if (count($parts) !== 3) {
            throw new \RuntimeException('Неверный формат токена');
        }
        [$h, $p, $sig] = $parts;

        // Проверяем подпись через timing-safe сравнение
        $expected = base64url_encode(
            hash_hmac('sha256', "$h.$p", $this->secret, true)
        );
        if (!hash_equals($expected, $sig)) {
            throw new \RuntimeException('Подпись не совпадает');
        }

        $payload = json_decode(base64url_decode($p), true);
        if (!is_array($payload)) {
            throw new \RuntimeException('Повреждённая полезная нагрузка');
        }
        if (isset($payload['exp']) && $payload['exp'] < time()) {
            throw new \RuntimeException('Токен истёк');
        }
        return $payload;
    }
}

Обратите внимание на три детали. Первая — hash_equals вместо ===: обычное сравнение уязвимо к timing-атакам, когда злоумышленник по времени ответа подбирает символы подписи. Вторая — random_bytes для jti: это криптографически стойкий генератор, в отличие от uniqid или mt_rand. Третья — JSON_UNESCAPED_UNICODE: иначе кириллица в claims превратится в Иван и токен раздуется в три раза.

Логин: выдача access и refresh

Контроллер логина принимает email и пароль, проверяет их через password_verify и возвращает пару токенов. Refresh-токен сохраняем в HttpOnly-куке, access — отдаём в JSON для localStorage или памяти SPA.

<?php
// POST /api/login
$data = json_decode(file_get_contents('php://input'), true);
$email = $data['email'] ?? '';
$password = $data['password'] ?? '';

$stmt = $pdo->prepare('SELECT id, password_hash FROM users WHERE email = ?');
$stmt->execute([$email]);
$user = $stmt->fetch();

if (!$user || !password_verify($password, $user['password_hash'])) {
    http_response_code(401);
    echo json_encode(['error' => 'Неверный логин или пароль']);
    exit;
}

$jwt = new JwtService($_ENV['JWT_SECRET']);
$access = $jwt->issueAccess([
    'sub' => (int)$user['id'],
    'email' => $email,
]);
$refresh = $jwt->issueRefresh((int)$user['id']);

// Сохраняем refresh в БД для возможности отзыва
$pdo->prepare('INSERT INTO refresh_tokens (user_id, token, expires_at) VALUES (?, ?, ?)')
   ->execute([$user['id'], hash('sha256', $refresh), date('Y-m-d H:i:s', time() + 604800)]);

// HttpOnly-кука недоступна JavaScript — защита от XSS
setcookie('refresh_token', $refresh, [
    'expires' => time() + 604800,
    'path' => '/api/refresh',
    'httponly' => true,
    'secure' => true,
    'samesite' => 'Strict',
]);

echo json_encode(['access_token' => $access, 'expires_in' => 900]);

В таблицу refresh_tokens кладём не сам токен, а его SHA-256 хеш. Если базу сольют, злоумышленник не сможет использовать токены — для проверки нужен оригинал. Это тот же принцип, по которому пароли хранят как bcrypt-хеши, а не открытым текстом.

Middleware для защиты эндпоинтов

Любой защищённый запрос проходит через middleware, которое достаёт токен из Authorization: Bearer ... и валидирует его. На выходе получаем массив claims или 401-ю ошибку.

<?php
function authenticate(JwtService $jwt): array
{
    $header = $_SERVER['HTTP_AUTHORIZATION'] ?? '';
    if (!preg_match('/^Bearer\s+(.+)$/', $header, $m)) {
        http_response_code(401);
        echo json_encode(['error' => 'Нет Authorization заголовка']);
        exit;
    }

    try {
        $claims = $jwt->decode($m[1]);
    } catch (\RuntimeException $e) {
        http_response_code(401);
        echo json_encode(['error' => $e->getMessage()]);
        exit;
    }

    if (($claims['typ'] ?? '') !== 'access') {
        http_response_code(401);
        echo json_encode(['error' => 'Ожидается access-токен']);
        exit;
    }

    return $claims;
}

// Использование в защищённом эндпоинте:
$user = authenticate($jwt);
echo json_encode(['hello' => $user['email'], 'user_id' => $user['sub']]);

Проверка typ === 'access' важна: без неё клиент сможет аутентифицироваться refresh-токеном, что ломает всю модель безопасности. Refresh должен ходить только на специальный эндпоинт /api/refresh.


Обновление токена и ротация

Когда access истекает, фронтенд автоматически шлёт refresh на /api/refresh. Сервер проверяет, что refresh ещё в БД и не отозван, выдаёт новую пару токенов и инвалидирует старый refresh. Это называется ротация refresh-токенов — стандартная защита от утечки.

<?php
// POST /api/refresh
$refresh = $_COOKIE['refresh_token'] ?? '';
if (!$refresh) {
    http_response_code(401);
    exit(json_encode(['error' => 'Нет refresh-токена']));
}

try {
    $claims = $jwt->decode($refresh);
} catch (\RuntimeException $e) {
    http_response_code(401);
    exit(json_encode(['error' => $e->getMessage()]));
}

if (($claims['typ'] ?? '') !== 'refresh') {
    http_response_code(401);
    exit(json_encode(['error' => 'Ожидается refresh']));
}

// Проверяем, что токен не отозван
$hash = hash('sha256', $refresh);
$stmt = $pdo->prepare(
    'SELECT id FROM refresh_tokens WHERE token = ? AND revoked = 0 AND expires_at > NOW()'
);
$stmt->execute([$hash]);
if (!$stmt->fetch()) {
    // Токен валиден по подписи, но отозван — возможно, кража
    // Отзываем ВСЕ токены пользователя для безопасности
    $pdo->prepare('UPDATE refresh_tokens SET revoked = 1 WHERE user_id = ?')
       ->execute([$claims['sub']]);
    http_response_code(401);
    exit(json_encode(['error' => 'Токен отозван']));
}

// Инвалидируем старый
$pdo->prepare('UPDATE refresh_tokens SET revoked = 1 WHERE token = ?')->execute([$hash]);

// Выдаём новую пару (как при логине)
$newAccess = $jwt->issueAccess(['sub' => $claims['sub']]);
$newRefresh = $jwt->issueRefresh($claims['sub']);
// ... сохраняем новый refresh в БД и куку
echo json_encode(['access_token' => $newAccess, 'expires_in' => 900]);

Ключевой момент — если приходит валидный по подписи, но отсутствующий в БД refresh-токен, это сигнал кражи. Кто-то сделал ротацию, а старый токен всё ещё используется. Правильная реакция — отозвать все refresh-токены пользователя и заставить его перелогиниться.

Типичные ошибки и чеклист безопасности

Реализация JWT — место, где легко наделать дыр. Несколько граблей, на которые наступают чаще всего:

  • Алгоритм none. Если бездумно доверять полю alg из заголовка, злоумышленник пришлёт токен с "alg":"none" и пустой подписью. В нашем классе мы хардкодим HS256 при создании, а при проверке вообще не смотрим в header — берём ожидаемый алгоритм из конфига.
  • RS256 vs HS256 confusion. Если используете RSA, не позволяйте подменить алгоритм на HMAC: тогда публичный ключ станет «секретом» и подделать токен сможет любой.
  • Хранение в localStorage. Любой XSS читает localStorage. Refresh должен лежать в HttpOnly+Secure+SameSite куке. Access можно хранить в памяти JS (переменная), но не в localStorage для долгоживущих сессий.
  • Длинный TTL access-токена. 24 часа — это приглашение для атак. Нормальное значение — 5–15 минут, плюс refresh на неделю.
  • Слабый секрет. Секрет HS256 должен быть не короче 32 случайных байт: openssl rand -base64 32. И только в переменных окружения, не в Git.
  • Чувствительные данные в payload. JWT не шифруется, только подписывается. Любой откроет jwt.io и прочитает содержимое. Не кладите туда пароли, токены платёжных систем, персональные данные.
  • Отсутствие jti. Без уникального идентификатора токена нельзя реализовать чёрный список и отзыв.

Итог

Собственная реализация JWT на PHP занимает меньше 100 строк и даёт полный контроль над логикой. Главные правила, которые нельзя нарушать:

  1. Алгоритм фиксируем в коде — никогда не доверяем полю alg из самого токена.
  2. Подписи сравниваем через hash_equals, а не ===.
  3. Access-токены живут 5–15 минут, refresh — 7 дней с ротацией.
  4. Refresh храним в HttpOnly-куке + хеш в БД для возможности отзыва.
  5. Секрет — минимум 32 байта из openssl rand, в переменной окружения.
  6. В payload только идентификатор пользователя и роль — никаких чувствительных данных.

Этот класс готов к продакшену для большинства типовых задач. Если нужны RS256 (асимметричная подпись для распределённых сервисов), JWE (шифрование payload) или JWKS (динамические ключи) — там уже стоит подключать web-token/jwt-framework, чтобы не изобретать криптографию самому.