Что такое JWT токен — Как работает авторизация без сессий

Что такое JWT токен и как работает авторизация

В прошлой статье мы подробно разобрали разницу между идентификацией, аутентификацией и авторизацией. Мы выяснили, что после успешной проверки пользователя (аутентификации) сервер должен как-то «запомнить» его, чтобы при каждом следующем клике не запрашивать логин и пароль заново.

Раньше для этого повсеместно использовались серверные сессии. Но с развитием микросервисов и мобильных приложений этот подход стал узким местом. На смену сессиям пришел JWT (JSON Web Token) — открытый стандарт для безопасной передачи информации между двумя сторонами в виде JSON-объекта.

В этой статье мы разберем, как устроен JWT, почему он стал стандартом де-факто для современных веб-приложений и как правильно с ним работать.

Сессии против Токенов — Почему JWT победил

Представьте классический механизм сессий: пользователь вводит логин и пароль, сервер проверяет их, создает у себя в оперативной памяти или базе данных запись (сессию) и отправляет клиенту короткий session_id. При каждом следующем запросе клиент присылает этот ID, а сервер ищет его у себя в памяти, чтобы понять, кто именно к нему обращается.

Проблема сессий заключается в масштабируемости (Stateful-архитектура). Если у вас работают три сервера (балансировка нагрузки), и пользователь авторизовался на первом, то второй сервер ничего не знает об этой сессии. Приходится либо настраивать сложную синхронизацию через Redis, либо «привязывать» пользователя к одному серверу.

Решение с JWT (Stateless-архитектура) элегантно и просто: сервер не хранит у себя состояние пользователя. После успешного входа он берет данные пользователя (например, его ID и роль), ставит на них криптографическую подпись и отдает клиенту в виде токена.

Теперь при каждом запросе клиент отправляет этот токен. Сервер просто проверяет свою же подпись. Если подпись верна — сервер доверяет информации внутри токена. Ему больше не нужно обращаться к базе данных или Redis, чтобы узнать, кто этот пользователь.

Структура JWT — Из чего состоит токен

Если вы когда-нибудь видели JWT, то знаете, что это длинная строка из непонятных символов, разделенная двумя точками. Токен всегда состоит из трех частей:

  1. Header (Заголовок)
  2. Payload (Полезная нагрузка)
  3. Signature (Подпись)

Выглядит он примерно так: xxxxx.yyyyy.zzzzz

Давайте разберем каждую часть отдельно.

Header (Заголовок)

Заголовок обычно состоит из двух частей: типа токена (JWT) и алгоритма хэширования, который используется для подписи (например, HMAC SHA256 или RSA).

{
  "alg": "HS256",
  "typ": "JWT"
}

Этот JSON кодируется в формат Base64Url, образуя первую часть токена (xxxxx).

Payload (Полезная нагрузка)

Здесь хранятся сами данные пользователя (claims). Существуют стандартные поля, например:

  • sub (subject) — идентификатор пользователя.
  • exp (expiration time) — время жизни токена (Unix Timestamp).
  • iat (issued at) — время создания токена.

Вы также можете добавлять свои кастомные поля (например, роль или email):

{
  "sub": "1234567890",
  "name": "Ivan Ivanov",
  "role": "admin",
  "exp": 1711622400
}

Этот JSON также кодируется в Base64Url, образуя вторую часть токена (yyyyy).

Важно: Данные в Payload, обычно, не зашифрованы, они просто закодированы. Любой человек, перехвативший ваш токен, может легко прочитать его содержимое (например, на сайте jwt.io). Поэтому никогда не храните в токене пароли или другие секретные данные.

Однако, никто вам не запрещает данные внутри объекта дополнительно шифровать, а если не используете готовые библиотеки, так и вовсе весь Payload организовать с учетом ваших желаний и требований.

Signature (Подпись)

Подпись — это самая важная часть JWT. Именно она гарантирует, что токен не был изменен злоумышленником.

Чтобы создать подпись, сервер берет закодированный Header, закодированный Payload, добавляет к ним секретный ключ (который хранится только на сервере) и прогоняет всё это через алгоритм, указанный в заголовке (например, HS256).

HMACSHA256(
  base64UrlEncode(header) + "." + base64UrlEncode(payload),
  secret_key
)

Результат образует третью часть токена (zzzzz).

Если хакер перехватит токен и попытается изменить свою роль в Payload с user на admin, то подпись станет недействительной. Когда этот измененный токен придет на сервер, сервер заново вычислит подпись на основе новых данных, сравнит ее с той, что пришла в токене, увидит несовпадение и отклонит запрос.

Access и Refresh токены — Залог безопасности

Так как сервер не хранит токены у себя, он не может просто «удалить» или отозвать конкретный токен, если его украли. Токен будет валидным до тех пор, пока не истечет его срок действия (exp).

Чтобы минимизировать риски, архитектуру делят на два типа токенов:

  1. Access Token (Токен доступа): Короткоживущий токен (обычно 15-30 минут). Именно его клиент отправляет с каждым запросом к API. В нем хранится основная информация о пользователе (ID, роли). Если его украдут, злоумышленник сможет им пользоваться лишь очень короткое время.
  2. Refresh Token (Токен обновления): Долгоживущий токен (от нескольких дней до нескольких месяцев). Его единственная задача — получить новый Access Token, когда старый истек. Он хранится в базе данных сервера и может быть легко отозван (например, при выходе из аккаунта или смене пароля).
Жизненный цикл токенов
  1. Пользователь вводит логин и пароль.
  2. Сервер проверяет их и выдает пару: Access Token и Refresh Token.
  3. Клиент сохраняет их и прикрепляет Access Token к каждому запросу (обычно в заголовке Authorization: Bearer <token>).
  4. Когда Access Token протухает, сервер возвращает ошибку 401 Unauthorized.
  5. Клиент автоматически (в фоновом режиме) отправляет Refresh Token на специальный эндпоинт (например, /api/refresh).
  6. Сервер проверяет Refresh Token в базе данных и, если всё ок, выдает новую пару токенов.

Где безопасно хранить токены на клиенте

Самая частая ошибка новичков — хранить Access Token в localStorage браузера. Это делает ваше приложение уязвимым к XSS (Cross-Site Scripting) атакам. Если на вашем сайте окажется вредоносный JavaScript-код, он сможет легко прочитать localStorage и украсть токен.

Правильный подход для веб-приложений (SPA):

  • Refresh Token нужно хранить в HttpOnly Cookie. Флаг HttpOnly запрещает доступ к куки из JavaScript, что защищает от XSS. Также желательно использовать флаги Secure (передача только по HTTPS) и SameSite=Strict (защита от CSRF).
  • Access Token можно хранить в оперативной памяти приложения (например, в переменной React-состояния) или также в HttpOnly Cookie.

Примеры работы с JWT на разных языках

Пример на Python

Давайте посмотрим, как создать и проверить токен с использованием библиотеки PyJWT:

import jwt
import datetime
from typing import Dict, Any

# Секретный ключ (в реальности должен быть в Переменные окружения:
# .env файл и прочих конфигурационных файлах вне кода).
SECRET_KEY = "super_secret_production_key"
ALGORITHM = "HS256"

def create_access_token(user_id: int, role: str) -> str:
    """Создает токен с временем жизни 15 минут"""
    expire = datetime.datetime.now(datetime.timezone.utc) + datetime.timedelta(minutes=15)

    payload = {
        "sub": str(user_id),
        "role": role,
        "exp": expire,
        "iat": datetime.datetime.now(datetime.timezone.utc)
    }

    encoded_jwt = jwt.encode(payload, SECRET_KEY, algorithm=ALGORITHM)
    return encoded_jwt

def verify_token(token: str) -> Dict[str, Any]:
    """Проверяет токен и возвращает его payload"""
    try:
        # Библиотека автоматически проверяет подпись и срок действия (exp)
        payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
        print(f"Токен валиден. Пользователь: {payload.get('sub')}, Роль: {payload.get('role')}")
        return payload
    except jwt.ExpiredSignatureError:
        print("Ошибка — Срок действия токена истек.")
        return {}
    except jwt.InvalidTokenError:
        print("Ошибка — Недействительный токен.")
        return {}

# 1. Генерация токена при успешном логине
token = create_access_token(user_id=42, role="admin")
print(f"Сгенерированный токен:\n{token}\n")

# 2. Проверка токена при защищенном запросе
verify_token(token)

Пример на Go (Golang)

В экосистеме Go стандартом является библиотека github.com/golang-jwt/jwt/v5. Обратите внимание на то, как строго типизированы поля (claims).

package main

import (
	"fmt"
	"time"

	"github.com/golang-jwt/jwt/v5"
)

var secretKey = []byte("super_secret_production_key")

// Описываем кастомные поля поверх стандартных (RegisteredClaims)
type CustomClaims struct {
	Role string `json:"role"`
	jwt.RegisteredClaims
}

// Создание токена
func CreateToken(userID string, role string) (string, error) {
	claims := CustomClaims{
		Role: role,
		RegisteredClaims: jwt.RegisteredClaims{
			Subject:   userID,
			ExpiresAt: jwt.NewNumericDate(time.Now().Add(15 * time.Minute)),
			IssuedAt:  jwt.NewNumericDate(time.Now()),
		},
	}

	token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
	return token.SignedString(secretKey)
}

// Проверка токена
func VerifyToken(tokenString string) (*CustomClaims, error) {
	token, err := jwt.ParseWithClaims(tokenString, &CustomClaims{}, func(token *jwt.Token) (interface{}, error) {
		return secretKey, nil
	})

	if err != nil {
		return nil, err
	}

	if claims, ok := token.Claims.(*CustomClaims); ok && token.Valid {
		return claims, nil
	}

	return nil, fmt.Errorf("недействительный токен")
}

func main() {
	token, _ := CreateToken("42", "admin")
	fmt.Printf("Сгенерированный токен:\n%s\n\n", token)

	claims, err := VerifyToken(token)
	if err != nil {
		fmt.Println("Ошибка:", err)
		return
	}
	fmt.Printf("Токен валиден. Пользователь: %s, Роль: %s\n", claims.Subject, claims.Role)
}

Пример на TypeScript

Для работы в Node.js чаще всего используют пакет jsonwebtoken. В связке с TypeScript нам также понадобятся типы @types/jsonwebtoken для автодополнения.

import jwt, { Secret, JwtPayload } from 'jsonwebtoken';

const SECRET_KEY: Secret = 'super_secret_production_key';

interface CustomJwtPayload extends JwtPayload {
  role: string;
}

// Создание токена
function createAccessToken(userId: number, role: string): string {
  const payload = {
    sub: userId.toString(),
    role: role,
  };

  // Библиотека сама добавит iat и exp, опираясь на expiresIn
  return jwt.sign(payload, SECRET_KEY, { expiresIn: '15m' });
}

// Проверка токена
function verifyToken(token: string): CustomJwtPayload | null {
  try {
    const decoded = jwt.verify(token, SECRET_KEY) as CustomJwtPayload;
    console.log(`Токен валиден. Пользователь: ${decoded.sub}, Роль: ${decoded.role}`);
    return decoded;
  } catch (error) {
    if (error instanceof jwt.TokenExpiredError) {
      console.log('Ошибка — Срок действия токена истек.');
    } else {
      console.log('Ошибка — Недействительный токен.');
    }
    return null;
  }
}

// 1. Генерация
const token = createAccessToken(42, 'admin');
console.log(`Сгенерированный токен:\n${token}\n`);

// 2. Проверка
verifyToken(token);

Заключение

JWT — это мощный инструмент для построения масштабируемых систем авторизации. Главное правило: не храните в нем секреты, используйте связку Access/Refresh токенов и защищайте токены на стороне клиента с помощью правильных механизмов хранения.

Теперь, когда вы понимаете базу идентификации, аутентификации, авторизации и механику работы токенов, вы готовы проектировать надежную и безопасную архитектуру для своих приложений.