Разберём, как написать собственную 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. Сервер проверяет подпись секретным ключом и доверяет содержимому без обращения к базе данных.

Главное отличие от классической сессии — состояние не хранится на сервере. Это плюс для горизонтального масштабирования (любой инстанс примет токен) и минус для отзыва: чтобы выкинуть пользователя до истечения 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 строк и даёт полный контроль над логикой. Главные правила, которые нельзя нарушать:
- Алгоритм фиксируем в коде — никогда не доверяем полю
algиз самого токена. - Подписи сравниваем через
hash_equals, а не===. - Access-токены живут 5–15 минут, refresh — 7 дней с ротацией.
- Refresh храним в HttpOnly-куке + хеш в БД для возможности отзыва.
- Секрет — минимум 32 байта из
openssl rand, в переменной окружения. - В payload только идентификатор пользователя и роль — никаких чувствительных данных.
Этот класс готов к продакшену для большинства типовых задач. Если нужны RS256 (асимметричная подпись для распределённых сервисов), JWE (шифрование payload) или JWKS (динамические ключи) — там уже стоит подключать web-token/jwt-framework, чтобы не изобретать криптографию самому.
