Django-Admin.ru

Авторизация в FastAPI через JWT

Авторизация в FastAPI с JWT: OAuth2PasswordBearer, хэширование паролей через pwdlib и Argon2, выпуск и проверка токена PyJWT, get_current_user и ошибки 401.

Редакция Django-Admin.ru
· обновлено · 12 мин чтения

Содержание · 12 разделов
  1. Как работает JWT-авторизация
  2. Установка
  3. Секретный ключ и настройки
  4. Хэширование паролей: pwdlib
  5. Полный пример
  6. OAuth2PasswordBearer
  7. Эндпоинт /token и OAuth2PasswordRequestForm
  8. create_access_token и срок жизни
  9. get_current_user
  10. Проверка: регистрация, вход и запрос с токеном
  11. Права доступа: 401 и 403
  12. Переход с passlib и bcrypt
  13. Частые ошибки
  14. 401 Not authenticated, хотя токен передан
  15. 422 при запросе к /token
  16. InvalidSubjectError: Subject must be a string
  17. Все токены перестали работать после перезапуска
  18. ModuleNotFoundError: No module named ‘jose’ или ‘passlib’
  19. Чек-лист
  20. Частые вопросы
  21. Где хранить токен на клиенте?
  22. Как сделать выход из системы (logout)?
  23. Как реализовать refresh-токен?
  24. Можно ли входить по email вместо имени пользователя?
  25. Итог

Авторизация в FastAPI через JWT строится так: клиент отправляет логин и пароль на /token, сервер проверяет пароль по хэшу и возвращает подписанный JWT-токен. Дальше клиент передаёт токен в заголовке Authorization: Bearer <токен>, а зависимость get_current_user проверяет подпись и срок действия и возвращает пользователя:

@app.get("/users/me")
def read_me(user: Annotated[User, Depends(get_current_user)]):
    return user

Соберём полный рабочий пример на тех же библиотеках, что и официальный учебник FastAPI: OAuth2PasswordBearer, хэширование паролей через pwdlib с Argon2, токены через PyJWT, регистрация и вход, защищённые эндпоинты, срок жизни токена, роли и разбор ошибок 401. Код проверен на FastAPI 0.143, PyJWT 2.15, pwdlib 0.3 и Python 3.12. Основы фреймворка — в руководстве по FastAPI.

Как работает JWT-авторизация

JWT (JSON Web Token) — строка из трёх частей, разделённых точками: заголовок, полезная нагрузка (payload) и подпись. Payload — это закодированный в Base64URL JSON, например:

{'sub': 'anna', 'iat': 1791533673, 'exp': 1791535473}
  • sub (subject) — кому выдан токен, обычно имя или ID пользователя;
  • iat — время выпуска, exp — время истечения (Unix-время).

Подпись вычисляется секретным ключом сервера. Подделать или изменить токен без ключа нельзя, поэтому серверу не нужно хранить выданные токены — достаточно проверить подпись и exp. Но payload не зашифрован: его может прочитать любой, у кого есть токен. Не кладите туда пароли и персональные данные.

Схема запросов:

  1. POST /token с полями username и password в виде формы → {"access_token": "...", "token_type": "bearer"}.
  2. GET /users/me с заголовком Authorization: Bearer <access_token> → данные пользователя.
  3. Токен истёк или подделан → 401 Unauthorized, клиент снова идёт на /token.

Установка

pip install "fastapi[standard]" pyjwt "pwdlib[argon2]"

Что ставится и зачем:

  • PyJWT — создание и проверка токенов. До июля 2024 года (FastAPI 0.111.1) учебник FastAPI использовал python-jose, поэтому в старых статьях встречается from jose import jwt; для нового кода берите PyJWT.
  • pwdlib с Argon2 — хэширование паролей. До сентября 2025 года (FastAPI 0.118.0) в учебнике был passlib[bcrypt], но passlib давно не обновляется. Если у вас уже есть база с bcrypt-хэшами, pwdlib умеет их проверять (см. раздел о переходе ниже).

Форма входа OAuth2PasswordRequestForm требует пакет python-multipart; он входит в fastapi[standard].

Секретный ключ и настройки

import os

SECRET_KEY = os.environ.get("SECRET_KEY", "insecure-dev-key-change-me-in-production")
ALGORITHM = "HS256"
ACCESS_TOKEN_EXPIRE_MINUTES = 30

Ключ должен быть длинным и случайным и храниться вне кода — в переменной окружения или .env. Сгенерировать его можно так:

python -c "import secrets; print(secrets.token_hex(32))"   # Windows
python3 -c "import secrets; print(secrets.token_hex(32))"  # macOS / Linux

Для HS256 ключ должен быть не короче 32 байт: начиная с PyJWT 2.11 более короткий ключ вызывает предупреждение InsecureKeyLengthWarning. secrets.token_hex(32) даёт 64 символа, этого достаточно.

HS256 — симметричный алгоритм: один ключ и подписывает, и проверяет токен. Если токены должны проверять другие сервисы без доступа к секрету, используют асимметричные алгоритмы (RS256, ES256); для них нужен pip install "pyjwt[crypto]".

Хэширование паролей: pwdlib

Пароли нельзя хранить в открытом виде — только хэш. PasswordHash.recommended() возвращает настроенный хэшер Argon2:

from pwdlib import PasswordHash

password_hash = PasswordHash.recommended()

hashed = password_hash.hash("s3cret-pass")
print(hashed)
# $argon2id$v=19$m=65536,t=3,p=4$efUa4PNgHZ88WaxexE76MQ$3Twh8qtBpXkpq/TAlIqkgPrOufklxvmddyRQQ4YP6RY

password_hash.verify("s3cret-pass", hashed)  # True
password_hash.verify("wrong", hashed)        # False

В строке хэша записаны алгоритм, параметры и соль, поэтому хранить соль отдельно не нужно. Каждый вызов hash() даёт новую строку для одного и того же пароля — это нормально.

Полный пример

Пользователи для краткости хранятся в словаре. В реальном проекте это таблица в базе — как подключить её, рассказано в статье FastAPI и SQLAlchemy.

# main.py
import os
from datetime import datetime, timedelta, timezone
from typing import Annotated

import jwt
from fastapi import Depends, FastAPI, HTTPException, status
from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm
from jwt.exceptions import InvalidTokenError
from pwdlib import PasswordHash
from pydantic import BaseModel, Field

SECRET_KEY = os.environ.get("SECRET_KEY", "insecure-dev-key-change-me-in-production")
ALGORITHM = "HS256"
ACCESS_TOKEN_EXPIRE_MINUTES = 30

password_hash = PasswordHash.recommended()
DUMMY_HASH = password_hash.hash("dummy-password")

oauth2_scheme = OAuth2PasswordBearer(tokenUrl="token")

app = FastAPI()


class UserCreate(BaseModel):
    username: str = Field(min_length=3, max_length=50)
    password: str = Field(min_length=8)


class User(BaseModel):
    username: str
    is_active: bool = True
    is_admin: bool = False


class UserInDB(User):
    hashed_password: str


class Token(BaseModel):
    access_token: str
    token_type: str


# Вместо базы данных — словарь
users_db: dict[str, UserInDB] = {}


def get_user(username: str) -> UserInDB | None:
    return users_db.get(username)


def authenticate_user(username: str, password: str) -> UserInDB | None:
    user = get_user(username)
    if user is None:
        password_hash.verify(password, DUMMY_HASH)  # выравниваем время ответа
        return None
    if not password_hash.verify(password, user.hashed_password):
        return None
    return user


def create_access_token(subject: str, expires_delta: timedelta) -> str:
    now = datetime.now(timezone.utc)
    payload = {"sub": subject, "iat": now, "exp": now + expires_delta}
    return jwt.encode(payload, SECRET_KEY, algorithm=ALGORITHM)


@app.post("/register", response_model=User, status_code=status.HTTP_201_CREATED)
def register(data: UserCreate):
    if get_user(data.username):
        raise HTTPException(status_code=400, detail="Пользователь уже существует")
    user = UserInDB(
        username=data.username,
        hashed_password=password_hash.hash(data.password),
    )
    users_db[user.username] = user
    return user


@app.post("/token")
def login(form_data: Annotated[OAuth2PasswordRequestForm, Depends()]) -> Token:
    user = authenticate_user(form_data.username, form_data.password)
    if user is None:
        raise HTTPException(
            status_code=status.HTTP_401_UNAUTHORIZED,
            detail="Неверное имя пользователя или пароль",
            headers={"WWW-Authenticate": "Bearer"},
        )
    access_token = create_access_token(
        user.username, timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
    )
    return Token(access_token=access_token, token_type="bearer")


def get_current_user(token: Annotated[str, Depends(oauth2_scheme)]) -> UserInDB:
    credentials_exception = HTTPException(
        status_code=status.HTTP_401_UNAUTHORIZED,
        detail="Не удалось проверить учётные данные",
        headers={"WWW-Authenticate": "Bearer"},
    )
    try:
        payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
    except InvalidTokenError:
        raise credentials_exception
    username = payload.get("sub")
    if username is None:
        raise credentials_exception
    user = get_user(username)
    if user is None:
        raise credentials_exception
    return user


def get_current_active_user(
    user: Annotated[UserInDB, Depends(get_current_user)],
) -> UserInDB:
    if not user.is_active:
        raise HTTPException(status_code=400, detail="Пользователь заблокирован")
    return user


CurrentUser = Annotated[UserInDB, Depends(get_current_active_user)]


@app.get("/users/me", response_model=User)
def read_me(user: CurrentUser):
    return user

Разберём ключевые части.

OAuth2PasswordBearer

OAuth2PasswordBearer(tokenUrl="token") — зависимость, которая достаёт токен из заголовка Authorization: Bearer ... и возвращает его строкой. Если заголовка нет или схема не Bearer, она сразу отвечает 401 {"detail": "Not authenticated"} с заголовком WWW-Authenticate: Bearer. Сам токен она не проверяет — это задача get_current_user.

Параметр tokenUrl — относительный адрес эндпоинта выдачи токена. По нему Swagger UI на /docs показывает кнопку Authorize: введите логин и пароль, и все запросы из документации пойдут с токеном.

Эндпоинт /token и OAuth2PasswordRequestForm

По спецификации OAuth2 логин и пароль приходят формой (application/x-www-form-urlencoded), а не JSON, и поля называются именно username и password. OAuth2PasswordRequestForm разбирает такую форму. Ответ должен содержать access_token и token_type со значением bearer.

Обратите внимание на DUMMY_HASH: если пользователя не существует, мы всё равно вычисляем хэш. Без этого ответ для несуществующего логина приходил бы заметно быстрее, и по времени можно было бы перебором выяснить, какие логины зарегистрированы. Тот же приём есть в официальном учебнике FastAPI.

create_access_token и срок жизни

jwt.encode() принимает словарь, ключ и алгоритм. Значения datetime в полях exp и iat PyJWT сам переводит в Unix-время. Используйте время с часовым поясом (datetime.now(timezone.utc)), а не устаревший datetime.utcnow().

Срок жизни — компромисс. Короткий (15–30 минут) снижает ущерб от украденного токена, но заставляет чаще входить заново. Обычное решение — пара токенов: короткий access-токен и долгоживущий refresh-токен, который хранится надёжнее и используется только для получения нового access-токена. Это отдельная схема, выходящая за рамки статьи.

get_current_user

jwt.decode() одновременно проверяет подпись и срок действия. Параметр algorithms обязателен: он задаёт список допустимых алгоритмов и не даёт подсунуть токен, подписанный другим способом. Все ошибки проверки — подклассы InvalidTokenError:

ИсключениеКогда возникает
ExpiredSignatureErrorистёк срок exp: Signature has expired
InvalidSignatureErrorтокен подписан другим ключом: Signature verification failed
DecodeErrorстрока вообще не является JWT
InvalidSubjectErrorsub не строка: Subject must be a string

Поэтому достаточно одного except InvalidTokenError. Все ошибки превращаются в один и тот же ответ 401 — не сообщайте клиенту, что именно не так с токеном.

Проверка: регистрация, вход и запрос с токеном

Запустите сервер (fastapi dev main.py или uvicorn main:app --reload, подробнее — в статье про Uvicorn) и выполните запросы. В PowerShell используйте curl.exe вместо curl.

curl -X POST http://127.0.0.1:8000/register -H "Content-Type: application/json" -d '{"username": "anna", "password": "s3cret-pass"}'
# {"username":"anna","is_active":true,"is_admin":false}

curl -X POST http://127.0.0.1:8000/token -d "username=anna&password=s3cret-pass"
# {"access_token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJhbm5hIi...","token_type":"bearer"}

curl http://127.0.0.1:8000/users/me -H "Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."
# {"username":"anna","is_active":true,"is_admin":false}

То же в тестах через TestClient — с результатами для разных ситуаций:

from fastapi.testclient import TestClient

from main import app

client = TestClient(app)

client.post("/register", json={"username": "anna", "password": "s3cret-pass"})
# 201 {"username": "anna", "is_active": true, "is_admin": false}

response = client.post("/token", data={"username": "anna", "password": "s3cret-pass"})
token = response.json()["access_token"]

client.get("/users/me", headers={"Authorization": f"Bearer {token}"})
# 200 {"username": "anna", "is_active": true, "is_admin": false}

client.get("/users/me")
# 401 {"detail": "Not authenticated"}

client.get("/users/me", headers={"Authorization": f"Bearer {token}x"})
# 401 {"detail": "Не удалось проверить учётные данные"}

client.post("/token", data={"username": "anna", "password": "wrong"})
# 401 {"detail": "Неверное имя пользователя или пароль"}

Истёкший токен проверяется так же — выпустите его с отрицательным сроком: create_access_token("anna", timedelta(minutes=-1)) даст ответ 401 «Не удалось проверить учётные данные».

Права доступа: 401 и 403

Аутентификация отвечает на вопрос «кто вы», авторизация — «что вам можно». Проверку прав удобно сделать ещё одной зависимостью поверх текущего пользователя:

def require_admin(user: CurrentUser) -> UserInDB:
    if not user.is_admin:
        raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Недостаточно прав")
    return user


@app.get("/admin/stats", dependencies=[Depends(require_admin)])
def admin_stats():
    return {"users": len(users_db)}

С токеном обычного пользователя /admin/stats вернёт 403 {"detail": "Недостаточно прав"}, с токеном администратора — 200 {"users": 1}. Правило: 401 — пользователь не опознан (нет токена, токен неверный или истёк), 403 — опознан, но прав не хватает.

Защитить сразу все эндпоинты роутера можно через APIRouter(dependencies=[Depends(get_current_active_user)]).

Переход с passlib и bcrypt

Если пароли в базе уже захэшированы bcrypt через passlib, не нужно просить пользователей сменить пароль. Установите pip install "pwdlib[argon2,bcrypt]" и перечислите оба хэшера — первый станет основным для новых хэшей:

from pwdlib import PasswordHash
from pwdlib.hashers.argon2 import Argon2Hasher
from pwdlib.hashers.bcrypt import BcryptHasher

password_hash = PasswordHash((Argon2Hasher(), BcryptHasher()))

valid, new_hash = password_hash.verify_and_update("s3cret-pass", old_bcrypt_hash)
# valid → True, new_hash → '$argon2id$...'
if valid and new_hash:
    user.hashed_password = new_hash  # сохраните в базе

verify_and_update() проверяет пароль любым подходящим хэшером и, если хэш устарел, возвращает новый — так база постепенно переходит на Argon2 при входе пользователей.

Частые ошибки

401 Not authenticated, хотя токен передан

Заголовок записан неверно: нужно ровно Authorization: Bearer <токен> — с пробелом и схемой Bearer. Вариант Token <токен> (как в DRF) OAuth2PasswordBearer не принимает.

422 при запросе к /token

Логин и пароль отправлены как JSON. Эндпоинт с OAuth2PasswordRequestForm ждёт форму: в TestClient — data={...}, а не json={...}, в curl — -d "username=...&password=...".

InvalidSubjectError: Subject must be a string

В sub положен ID пользователя числом: {"sub": 42}. Современные версии PyJWT (в том числе 2.15, на которой проверены примеры) отклоняют токен с нестроковым sub. Записывайте str(user.id) и приводите обратно к int при чтении.

Все токены перестали работать после перезапуска

SECRET_KEY генерируется при старте приложения (например, secrets.token_hex() прямо в коде) или отличается на разных серверах. Ключ должен быть постоянным и одинаковым для всех экземпляров приложения.

ModuleNotFoundError: No module named ‘jose’ или ‘passlib’

Код взят из старой версии документации. Перепишите на PyJWT (import jwt) и pwdlib или установите старые пакеты, если менять код пока нельзя. Как читать такие ошибки, — в статье про ModuleNotFoundError.

Чек-лист

  • SECRET_KEY случайный, длинный, хранится в переменных окружения
  • пароли хранятся только как хэши Argon2 (pwdlib)
  • в jwt.decode() передан algorithms=[...]
  • у токена есть exp, срок жизни — минуты, а не дни
  • в payload нет секретов и персональных данных
  • все ошибки токена дают одинаковый ответ 401 с WWW-Authenticate: Bearer
  • API работает только по HTTPS — иначе токен можно перехватить

Частые вопросы

Где хранить токен на клиенте?

Для SPA обычно выбирают между localStorage и cookie. Токен в localStorage прочитает любой скрипт на странице, поэтому XSS-уязвимость приводит к его краже. Cookie с флагами HttpOnly, Secure и SameSite недоступна JavaScript, но тогда нужна защита от CSRF. Мобильные приложения хранят токен в защищённом хранилище системы (Keychain, Keystore). Короткий срок жизни access-токена снижает ущерб в любом варианте.

Как сделать выход из системы (logout)?

JWT нельзя «отозвать» на сервере: токен действует до истечения exp. Для простого выхода клиент удаляет токен у себя. Если токен нужно сделать недействительным сразу, добавьте в payload уникальный идентификатор jti, при выходе сохраняйте его в чёрный список (например, в Redis до момента exp) и проверяйте этот список в get_current_user.

Как реализовать refresh-токен?

Выпускайте при входе два токена: access на 15–30 минут и refresh на дни или недели, с отметкой типа в payload, например "type": "refresh". Отдельный эндпоинт /refresh принимает refresh-токен, проверяет подпись, срок и тип и выдаёт новый access-токен. Refresh-токен не должен приниматься защищёнными эндпоинтами, а при выходе пользователя его стоит заносить в чёрный список.

Можно ли входить по email вместо имени пользователя?

Да. OAuth2PasswordRequestForm требует поле с именем username, но что в нём лежит, решаете вы: клиент отправляет email в username, а authenticate_user ищет пользователя по email. Кнопка Authorize в /docs продолжит работать, просто в поле «username» нужно вводить адрес почты.

Итог

JWT-авторизация в FastAPI — это три зависимости: OAuth2PasswordBearer достаёт токен из заголовка, get_current_user проверяет его через jwt.decode() и находит пользователя, а дополнительные зависимости вроде require_admin проверяют права. Пароли хэшируйте через pwdlib с Argon2, токены выпускайте PyJWT с ограниченным сроком жизни.

Следующий шаг — хранить пользователей в базе по статье FastAPI и SQLAlchemy. Если вы делаете API на Django, токены там выдаёт Simple JWT — он описан в руководстве по Django REST Framework. Официальный разбор схемы — в документации FastAPI по OAuth2 и JWT.

  • #FastAPI
  • #JWT
  • #Авторизация
  • #OAuth2
  • #Безопасность
↑ ↓ выборEnter открытьEsc закрыть