JWT авторизация в FastAPI: от теории к практике

Введение: почему сессии больше не нужны?

Представьте себе, что вы разработчик, и перед вами стоит задача: сделать так, чтобы после входа пользователь мог получать свои личные данные, не вводя пароль при каждом клике. Звучит как классическая задача аутентификации, правда?

Традиционный подход — сессии. Вы логинитесь, сервер создаёт сессию, запоминает ваш ID у себя в базе данных, а вам выдаёт куку с ID этой сессии. Всё работает, пока вы на одном сервере. А если у вас их два? Или десять? Куда девать сессии? Начинаются проблемы с синхронизацией, Redis, общими хранилищами…

Альтернатива — JWT (JSON Web Token). В этой статье мы с вами:

  • Разберём, что такое JWT и как он устроен

  • Напишем полноценный проект на FastAPI с нуля

  • Реализуем регистрацию, логин и защищённые эндпоинты

  • Разберём каждый кусочек кода, чтобы вы понимали, что происходит

И самое главное — я покажу полный путь запроса: от момента, когда вы нажимаете кнопку “Войти”, до того, как получаете ответ от сервера.

Часть 1. Что такое JWT и с чем его едят

JWT — это просто строка

JWT (читается как “джот”) — это JSON Web Token. На практике это просто длинная строка, которая выглядит примерно так:

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJhbGV4IiwibmFtZSI6IkFsZXggVG9tY2hvayIsImlhdCI6MTUxNjIzOTAyMiwiZXhwIjoxNTE2MjQyNjIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c

Но внутри этой строки скрывается структурированная информация. Разберём её по слоям.

Структура JWT

JWT состоит из трёх частей, разделённых точками:

  1. Header (заголовок) — информация о том, как подписан токен

  2. Payload (полезная нагрузка) — сами данные, которые мы хотим передать

  3. Signature (подпись) — результат шифрования заголовка и payload с нашим секретным ключом

Если декодировать (не расшифровать, а просто перевести из base64 в читаемый вид) наш токен, получим:

Header:

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

Payload:

{
  "sub": "alex",
  "name": "Alex Tomchok",
  "iat": 1516239022,
  "exp": 1516242622
}
  • sub (subject) — обычно здесь хранится идентификатор пользователя

  • iat (issued at) — время создания токена

  • exp (expiration) — время истечения

Почему JWT безопасен?

Самое важное: подпись. Когда сервер создаёт токен, он берёт заголовок + payload, добавляет секретный ключ (который знает только сервер) и создаёт подпись. При следующем запросе сервер снова берёт заголовок + payload, добавляет свой секретный ключ и сравнивает получившуюся подпись с той, что пришла в токене.

Если кто-то изменит данные в payload, подпись перестанет совпадать, и сервер отклонит токен. Данные в JWT не защищены от чтения — их может прочитать кто угодно, но изменить — никто.

Access token vs Refresh token

В реальных проектах используют два типа токенов:

  • Access token — живёт недолго (15-30 минут). Используется для доступа к API. Если его украдут, злоумышленник будет иметь доступ ограниченное время.

  • Refresh token — живёт долго (дни или месяцы). Используется только для получения нового access token, когда старый истёк.

В нашем проекте для простоты будем использовать только access token.

Часть 2. Готовим окружение

Создадим структуру проекта:

fastapi-jwt-auth/
├── app/
│   ├── __init__.py
│   ├── main.py
│   ├── core/
│   │   ├── __init__.py
│   │   ├── config.py
│   │   └── security.py
│   ├── schemas/
│   │   ├── __init__.py
│   │   ├── token.py
│   │   └── user.py
│   ├── api/
│   │   ├── __init__.py
│   │   └── v1/
│   │       ├── __init__.py
│   │       ├── endpoints/
│   │       │   ├── __init__.py
│   │       │   ├── auth.py
│   │       │   └── users.py
│   │       └── dependencies.py
│   └── services/
│       ├── __init__.py
│       └── user_service.py
└── requirements.txt

requirements.txt:

fastapi==0.104.1
uvicorn[standard]==0.24.0
python-jose[cryptography]==3.3.0
passlib[bcrypt]==1.7.4
python-multipart==0.0.6
pydantic[email]==2.5.0

Устанавливаем:

pip install -r requirements.txt

Часть 3. Архитектурная схема работы программы

Часть 4. Пишем код шаг за шагом

4.1. Конфигурация (core/config.py)

Начнём с настроек. Всё, что может меняться в зависимости от окружения, выносим в переменные:

from pydantic_settings import BaseSettings

class Settings(BaseSettings):
    # Секретный ключ — в реальном проекте брать из .env!
    SECRET_KEY: str = "your-super-secret-key-change-this-in-production"
    ALGORITHM: str = "HS256"
    ACCESS_TOKEN_EXPIRE_MINUTES: int = 30

    class Config:
        env_file = ".env"

settings = Settings()

Важно: В production секретный ключ должен храниться в переменных окружения, а не в коде!

4.2. Pydantic схемы (schemas/)

Схемы — это контракты. Они говорят: “Вот такие данные мы принимаем, и вот такие отдаём”.

schemas/user.py:

from pydantic import BaseModel, EmailStr

class UserBase(BaseModel):
    username: str
    email: EmailStr

class UserCreate(UserBase):
    password: str

class User(UserBase):
    id: int
    is_active: bool = True

    class Config:
        from_attributes = True

class UserInDB(User):
    hashed_password: str

Обратите внимание на from_attributes = True — это позволяет создавать Pydantic модели из SQLAlchemy моделей (или любых других объектов с атрибутами).

schemas/token.py:

from pydantic import BaseModel

class Token(BaseModel):
    access_token: str
    token_type: str = "bearer"

class TokenData(BaseModel):
    username: str | None = None

4.3. Безопасность (core/security.py)

Здесь происходит вся магия с хешированием паролей и JWT.

from datetime import datetime, timedelta
from jose import JWTError, jwt
from passlib.context import CryptContext
from .config import settings

# Настройка хеширования паролей (bcrypt — один из самых надёжных алгоритмов)
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")

def verify_password(plain_password: str, hashed_password: str) -> bool:
    """Проверяет, совпадает ли введённый пароль с хешем в базе"""
    return pwd_context.verify(plain_password, hashed_password)

def get_password_hash(password: str) -> str:
    """Хеширует пароль для сохранения в базу"""
    return pwd_context.hash(password)

def create_access_token(data: dict) -> str:
    """Создаёт JWT токен с данными из data"""
    to_encode = data.copy()
    expire = datetime.utcnow() + timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES)
    to_encode.update({"exp": expire})
    encoded_jwt = jwt.encode(
        to_encode, 
        settings.SECRET_KEY, 
        algorithm=settings.ALGORITHM
    )
    return encoded_jwt

def decode_access_token(token: str) -> dict:
    """Декодирует токен и возвращает payload, если подпись верна"""
    try:
        payload = jwt.decode(
            token, 
            settings.SECRET_KEY, 
            algorithms=[settings.ALGORITHM]
        )
        return payload
    except JWTError:
        return None

4.4. Сервис пользователей (services/user_service.py)

В реальном проекте здесь будет работа с базой данных. Для демонстрации используем простой список в памяти:

from app.schemas.user import UserInDB
from app.core.security import verify_password, get_password_hash

# Наша "база данных"
fake_users_db = {
    "alex": {
        "id": 1,
        "username": "alex",
        "email": "alex@example.com",
        "hashed_password": get_password_hash("secret123"),
        "is_active": True,
    }
}

def get_user_by_username(username: str) -> UserInDB | None:
    """Ищет пользователя по имени"""
    if username in fake_users_db:
        return UserInDB(**fake_users_db[username])
    return None

def authenticate_user(username: str, password: str) -> UserInDB | None:
    """Аутентифицирует пользователя: проверяет, что такой есть и пароль верный"""
    user = get_user_by_username(username)
    if not user:
        return None
    if not verify_password(password, user.hashed_password):
        return None
    return user

4.5. Зависимости для авторизации (api/v1/dependencies.py)

Зависимости в FastAPI — это мощный инструмент. Они позволяют вынести повторяющуюся логику (например, получение текущего пользователя) в отдельные функции.

from fastapi import Depends, HTTPException, status
from fastapi.security import OAuth2PasswordBearer
from app.core.security import decode_access_token
from app.services.user_service import get_user_by_username
from app.schemas.token import TokenData
from app.schemas.user import User

# OAuth2PasswordBearer — встроенная зависимость FastAPI
# Она извлекает токен из заголовка Authorization: Bearer <token>
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/v1/auth/login")

async def get_current_user(token: str = Depends(oauth2_scheme)) -> User:
    """
    Извлекает текущего пользователя из JWT токена.
    Это сердце нашей авторизации!
    """
    credentials_exception = HTTPException(
        status_code=status.HTTP_401_UNAUTHORIZED,
        detail="Could not validate credentials",
        headers={"WWW-Authenticate": "Bearer"},
    )
    
    # Декодируем токен
    payload = decode_access_token(token)
    if payload is None:
        raise credentials_exception
    
    # Извлекаем имя пользователя
    username: str = payload.get("sub")
    if username is None:
        raise credentials_exception
    
    # Проверяем, что пользователь существует
    user = get_user_by_username(username)
    if user is None:
        raise credentials_exception
    
    return user

async def get_current_active_user(
    current_user: User = Depends(get_current_user)
) -> User:
    """Проверяет, что пользователь не заблокирован"""
    if not current_user.is_active:
        raise HTTPException(
            status_code=status.HTTP_400_BAD_REQUEST, 
            detail="Inactive user"
        )
    return current_user

4.6. Эндпоинты аутентификации (api/v1/endpoints/auth.py)

Теперь создадим точку входа — эндпоинт /login. Он принимает username и password, проверяет их и выдёт JWT токен.

from fastapi import APIRouter, Depends, HTTPException, status
from fastapi.security import OAuth2PasswordRequestForm
from app.schemas.token import Token
from app.services.user_service import authenticate_user
from app.core.security import create_access_token

router = APIRouter(prefix="/auth", tags=["authentication"])

@router.post("/login", response_model=Token)
async def login(form_data: OAuth2PasswordRequestForm = Depends()):
    """
    Логин пользователя.
    Принимает username и password (form-data), возвращает JWT токен.
    """
    # Проверяем пользователя
    user = authenticate_user(form_data.username, form_data.password)
    if not user:
        raise HTTPException(
            status_code=status.HTTP_401_UNAUTHORIZED,
            detail="Incorrect username or password",
            headers={"WWW-Authenticate": "Bearer"},
        )
    
    # Создаём токен
    access_token = create_access_token(data={"sub": user.username})
    
    return Token(access_token=access_token, token_type="bearer")

4.7. Защищённые эндпоинты (api/v1/endpoints/users.py)

А вот и защищённый эндпоинт. Обратите внимание на зависимость — она сама позаботится о проверке токена.

from fastapi import APIRouter, Depends
from app.schemas.user import User
from app.api.v1.dependencies import get_current_active_user

router = APIRouter(prefix="/users", tags=["users"])

@router.get("/me", response_model=User)
async def read_users_me(current_user: User = Depends(get_current_active_user)):
    """
    Возвращает информацию о текущем пользователе.
    Требует валидный JWT токен в заголовке Authorization.
    """
    return current_user

4.8. Сборка приложения (main.py)

Всё собираем вместе:

from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from app.api.v1.endpoints import auth, users

app = FastAPI(
    title="FastAPI JWT Auth Demo",
    description="Пример реализации JWT авторизации на FastAPI",
    version="1.0.0"
)

# CORS для разработки
app.add_middleware(
    CORSMiddleware,
    allow_origins=["*"],
    allow_credentials=True,
    allow_methods=["*"],
    allow_headers=["*"],
)

# Регистрируем роутеры
app.include_router(auth.router, prefix="/api/v1")
app.include_router(users.router, prefix="/api/v1")

@app.get("/")
async def root():
    return {"message": "FastAPI JWT Auth Demo. Go to /docs for API documentation"}

if __name__ == "__main__":
    import uvicorn
    uvicorn.run(app, host="0.0.0.0", port=8000)

Часть 5. Полный путь запроса (по шагам)

Давайте проследим, что происходит, когда пользователь логинится, а потом обращается к защищённому эндпоинту.

5.1. Шаг 1: Логин (получение токена)

1. Клиент отправляет запрос:

POST http://localhost:8000/api/v1/auth/login
Content-Type: application/x-www-form-urlencoded

username=alex&password=secret123

2. FastAPI принимает запрос и направляет его в auth.login().

3. Валидация: OAuth2PasswordRequestForm автоматически проверяет, что поля username и password пришли и не пустые.

4. Аутентификация: Вызывается authenticate_user(), которая:

  • Ищет пользователя в базе

  • Проверяет пароль через verify_password()

5. Генерация токена: Вызывается create_access_token(), которая:

  • Создаёт payload: {"sub": "alex", "exp": <текущее_время+30минут>}

  • Подписывает его секретным ключом

  • Возвращает JWT строку

6. Ответ клиенту:

{
  "access_token": "eyJhbGciOiJIUzI1NiIs...",
  "token_type": "bearer"
}

5.2. Шаг 2: Доступ к защищённому ресурсу

1. Клиент отправляет запрос с токеном:

GET http://localhost:8000/api/v1/users/me
Authorization: Bearer eyJhbGciOiJIUzI1NiIs...

2. FastAPI направляет запрос в users.read_users_me().

3. Вызывается зависимость get_current_active_user().

4. Внутри зависимости:

  • get_current_user() извлекает токен из заголовка

  • Декодирует и проверяет подпись через decode_access_token()

  • Проверяет срок действия (exp)

  • Из payload’а достаёт sub (username)

  • Находит пользователя в базе

  • Возвращает объект пользователя

5. get_current_active_user() проверяет, что пользователь не заблокирован.

6. Выполняется бизнес-логика эндпоинта — возвращает данные пользователя.

7. Ответ клиенту:

{
  "id": 1,
  "username": "alex",
  "email": "alex@example.com",
  "is_active": true
}

Часть 6. Как это выглядит на практике (проверка через Swagger)

FastAPI автоматически генерирует документацию по адресу /docs. Вот как работает авторизация в Swagger:

  1. Перейдите на http://localhost:8000/docs

  2. Найдите эндпоинт /auth/login, нажмите “Try it out”

  3. Введите username и password, выполните запрос

  4. Скопируйте полученный access_token

  5. Нажмите кнопку “Authorize” вверху, введите Bearer <ваш_токен>

  6. Теперь можете выполнять запрос к /users/me — токен будет автоматически добавлен в заголовки

Часть 7. Подводные камни и лучшие практики

1. Где хранить секретный ключ?

Плохо:

SECRET_KEY = "my-super-secret-key"  # прямо в коде!

Хорошо:

import os
SECRET_KEY = os.getenv("JWT_SECRET_KEY")  # из переменных окружения

Создайте файл .env и добавьте его в .gitignore:

JWT_SECRET_KEY=your-super-secret-key-here

2. Какой срок жизни токена выбирать?

  • 15–30 минут — стандарт для access token

  • Если приложение требует долгой сессии — добавьте refresh token

3. Как обновлять токен?

Самый простой способ — refresh token:

  1. Клиент хранит refresh token (например, в httpOnly cookie)

  2. Когда access token истекает, клиент отправляет refresh token на /refresh

  3. Сервер проверяет refresh token и выдаёт новый access token

4. Всегда проверяйте подпись!

Никогда не доверяйте данным из payload, пока не проверили подпись токена. Злоумышленник может изменить sub на “admin”, и если вы просто прочитаете это поле без проверки — вы получите несанкционированный доступ.

Заключение

Мы с вами:

  • Разобрались, что такое JWT и как он устроен

  • Написали полноценное приложение на FastAPI с JWT авторизацией

  • Прошли полный путь запроса от начала до конца

  • Разобрали лучшие практики безопасности

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

Код из статьи доступен на GitHub: https://github.com/MaximBytecamp/js_projects/tree/9ade6e5616b8bbf523ac938bdc65d7db9c9e6585/fastapi-demo

Если у вас остались вопросы или вы нашли неточность — пишите в комментариях. Вместе сделаем код лучше!