Перейти к содержанию

172 уроков, 13 библиотек и челлендж «Что выведет код?» — бесплатно, код прямо в браузере

Начать обучение
Урок 8 из 10 Средний 45 мин 120 XP

Аутентификация по JWT: защищаем эндпоинты

Строим аутентификацию по JWT: хешируем пароли с солью, выдаём токен на /login и закрываем эндпоинты зависимостью get_current_user.

Редакция Питоники

Сервис задач с базой данных из седьмого урока выложили на хостинг — и теперь любой прохожий может прочитать и удалить чужие задачи: у эндпоинтов нет ни дверей, ни замков. Пора заводить учётные записи. Сегодня строим аутентификацию по JWT (JSON Web Token) — схему, которая стоит за половиной API планеты: регистрация с хешированием пароля, выдача токена на /login и зависимость get_current_user, которая закрывает эндпоинты от анонимов.

Хорошая новость: ничего нового изобретать не придётся. Зависимости и Depends вы отработали в прошлом уроке, модели и Field — в четвёртом, HTTPException со статусами — в шестом. Сегодня эти три инструмента собираются в замок на входе сервиса.

Схема аутентификации: register, login, Bearer

  1. POST /register — клиент присылает username и пароль, сервер сохраняет пользователя. Пароль уходит в базу только в виде хеша.
  2. POST /login — сервер сверяет пару логин-пароль и выдаёт подписанный токен со сроком жизни.
  3. Клиент хранит токен и прикладывает заголовок Authorization: Bearer <токен> к каждому запросу.
  4. Сервер проверяет подпись и срок токена — и только потом выполняет эндпоинт.

Почему токен, а не привычные сессии с куками? Токен stateless: сервер не хранит «кто вошёл» — вся информация зашита в самом токене и защищена подписью. Ни общей памяти, ни таблицы сессий, ни sticky-сессий на балансировщике: хоть три экземпляра сервиса в трёх дата-центрах — проверка подписи везде одинаковая. Мобильному приложению куки вообще не родные, а заголовок Authorization — универсальный стандарт.

Пароли не хранят открыто: хеш и соль

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

Хеширование пароля с солью (запускается в браузере)
import hashlib

def hash_password(password: str, salt: bytes) -> str:
    # В настоящем коде соль генерирует os.urandom(16) при регистрации;
    # здесь она фиксированная, чтобы вывод был воспроизводимым
    digest = hashlib.sha256(salt + password.encode()).hexdigest()
    return salt.hex() + "$" + digest

def verify_password(password: str, stored: str) -> bool:
    salt_hex, digest = stored.split("$")
    # Хешируем присланный пароль с той же солью и сравниваем
    return hash_password(password, bytes.fromhex(salt_hex)) == stored

SALT = bytes.fromhex("1a2b3c4d5e6f708192a3b4c5d6e7f809")
h1 = hash_password("кот-днём-спит", SALT)
h2 = hash_password("кот-днём-спит", bytes.fromhex("00ff00ff00ff00ff00ff00ff00ff00ff"))

print(h1)
print(h1 == h2)                          # пароль тот же, соль другая
print(verify_password("кот-днём-спит", h1))
print(verify_password("неверный", h1))
Вывод
1a2b3c4d5e6f708192a3b4c5d6e7f809$5e1cfc9df3d855fa30243dc5ed76ceb11df17456e212b3e71385b3f5a48310ec
False
True
False
В песочнице доступен hashlib — поэтому демо честное. Но в реальных проектах вместо sha256 берут bcrypt или argon2: причина — в следующем разделе.

Запустите блок и посмотрите на формат хранения: соль$хеш — соль лежит открыто рядом с хешем, и это нормально, она не секрет. Секретность обеспечивают пароль и стойкость функции, а соль нужна, чтобы одинаковые пароли разных людей превращались в разные хеши: первый print показал хеш, второй — что при другой соли он уже не совпадёт. Проверка при логине симметрична: пароль пользователя хешируется с его же сохранённой солью, и результат сверяется с тем, что в базе.

Почему sha256 — только для демо, а в проде bcrypt и argon2

sha256 считает хеш за микросекунды — это отлично для подписей файлов, но плохо для паролей: видеокарта перебирает миллиарды вариантов в секунду, и даже с солью короткий пароль подбирается за часы. bcrypt и argon2 — функции, которые намеренно медленные: проверка пароля занимает около 100 миллисекунд, а «фактор стоимости» можно повышать по мере роста процессоров. Для вас разница невелика — API почти тот же:

Как это выглядит с passlib и bcrypt в настоящем проекте
from passlib.context import CryptContext

pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")

hash_olga = pwd_context.hash("кот-днём-спит")
print(hash_olga[:7], "...")
print(pwd_context.verify("кот-днём-спит", hash_olga))  # True
print(pwd_context.verify("неверный", hash_olga))       # False
Вывод
$2b$12$ ...
True
False
passlib в песочницу не входит, запускается локально (pip install passlib[bcrypt]). Соль здесь генерируется сама и вшита в строку хеша: $2b$12$ — версия и фактор стоимости 12.

Дальше по уроку мы пользуемся функциями hash_password и verify_password как абстракцией: какие именно они внутри — sha256 для песочницы или bcrypt для продакшена — остальной код не волнует. Это хороший стиль: механизм хеширования изолирован в двух функциях и меняется в одном месте.

Регистрация: POST /register

Добавим к сервису из седьмого урока таблицу пользователей и эндпоинт регистрации. Обратите внимание на три решения: пароль принимаем только с минимумом длины (Field из урока 4), имя делаем уникальным на уровне базы, а наружу отдаём только username — без всяких хешей:

POST /register: создаём пользователя с хешем
class User(Base):
    __tablename__ = "users"

    id: Mapped[int] = mapped_column(primary_key=True)
    username: Mapped[str] = mapped_column(String(30), unique=True)
    password_hash: Mapped[str] = mapped_column(String(200))


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


class UserPublic(BaseModel):
    username: str


@app.post("/register", status_code=201, response_model=UserPublic)
def register(payload: UserCreate, db: Session = Depends(get_db)):
    exists = db.scalars(
        select(User).where(User.username == payload.username)
    ).first()
    if exists is not None:
        raise HTTPException(status_code=409, detail="Имя уже занято")
    user = User(
        username=payload.username,
        password_hash=hash_password(payload.password),
    )
    db.add(user)
    db.commit()
    return user
Вывод
{"username": "olga"}
Ответ на POST /register с телом {"username": "olga", "password": "..."}: статус 201 и только имя. Вторая попытка с тем же именем получит 409 Conflict.

Страховка от утечки — двойная. Первая: пароль превращается в хеш до того, как попадёт в ORM-объект, в колонке password_hash лежит уже необратимая строка. Вторая: response_model=UserPublic отрежет password_hash, даже если однажды вернёте полную модель по невнимательности — тот самый фильтр из шестого урока. Статус 409 вместо 400 — потому что конфликт не с форматом запроса, а с текущим состоянием базы: имя занято.

JWT: три строки, склеенные точками

Токен выглядит как абракадабра, но устроен примитивно: header.payload.signature — три куска base64, склеенные точками. header описывает алгоритм подписи, payload — данные (в терминах JWT — claims): кто пользователь, когда истекает токен. signature — подпись HMAC-SHA256 от первых двух частей секретным ключом сервера. Соберём такой токен руками — стандартный base64 и hmac доступны в песочнице:

JWT своими руками: header, payload, подпись (запускается в браузере)
import base64, hashlib, hmac, json

def b64url(data: bytes) -> str:
    # Вариант base64 для JWT: без переносов, без знаков = и +
    return base64.urlsafe_b64encode(data).rstrip(b"=").decode()

def sign(msg: bytes, secret: bytes) -> str:
    return b64url(hmac.new(secret, msg, hashlib.sha256).digest())

SECRET = b"dev-secret-change-me"

header = json.dumps({"alg": "HS256", "typ": "JWT"}, separators=(",", ":"))
payload = json.dumps(
    {"sub": "olga", "role": "user", "exp": 1752600000},
    separators=(",", ":"),
)

h = b64url(header.encode())
p = b64url(payload.encode())
s = sign((h + "." + p).encode(), SECRET)

print(h)
print(p)
print(s)
print(h + "." + p + "." + s)
Вывод
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9
eyJzdWIiOiJvbGdhIiwicm9sZSI6InVzZXIiLCJleHAiOjE3NTI2MDAwMDB9
gVtw5Frx6Zk9OOlcEXmu0bpuC4Bkfzr1AwkHlVaCUjc
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJvbGdhIiwicm9sZSI6InVzZXIiLCJleHAiOjE3NTI2MDAwMDB9.gVtw5Frx6Zk9OOlcEXmu0bpuC4Bkfzr1AwkHlVaCUjc
Первая строка — фирменное начало каждого HS256-токена: decode(base64) от неё даёт {"alg":"HS256","typ":"JWT"}. Настоящие библиотеки python-jose и PyJWT делают ровно то же самое.

Ключевые поля payload: sub (subject) — кто владелец токена, у нас имя пользователя; exp (expiration) — метка времени Unix, после которой токен недействителен. Проверьте сами: закодируйте первую строку вывода через base64.urlsafe_b64decode, дописав два знака ==, — и увидите JSON заголовка. Никакой магии, только кодировки и арифметика.

Подпись ловит подделку: пробуем заменить payload

Зачем вообще подпись? Злоумышленник видит структуру токена — и пробует выдать себя за админа: берёт payload, меняет sub и role на admin, подпись оставляет старую. Проверим, что из этого выйдет:

Попытка подделки и проверка подписи (запускается в браузере)
import base64, hashlib, hmac, json

def b64url(data: bytes) -> str:
    return base64.urlsafe_b64encode(data).rstrip(b"=").decode()

def sign(msg: bytes, secret: bytes) -> str:
    return b64url(hmac.new(secret, msg, hashlib.sha256).digest())

SECRET = b"dev-secret-change-me"

def make_token(payload: dict) -> str:
    head = b64url(json.dumps({"alg": "HS256", "typ": "JWT"}, separators=(",", ":")).encode())
    body = b64url(json.dumps(payload, separators=(",", ":")).encode())
    return head + "." + body + "." + sign((head + "." + body).encode(), SECRET)

def verify(token: str) -> bool:
    h, p, s = token.split(".")
    return hmac.compare_digest(sign((h + "." + p).encode(), SECRET), s)

good = make_token({"sub": "olga", "role": "user", "exp": 1752600000})
print(verify(good))  # свой токен

# Злоумышленник подменил payload, а подпись оставил старую
h, p, s = good.split(".")
bad_body = b64url(json.dumps(
    {"sub": "admin", "role": "admin", "exp": 1752600000},
    separators=(",", ":"),
).encode())
forged = h + "." + bad_body + "." + s
print(verify(forged))
Вывод
True
False
Смена одного байта payload ломает подпись, потому что HMAC считается по всему тексту header.payload. Секретным ключом злоумышленник не владеет — новую подпись ему не собрать.

Второй print вернул False — сервер отвергнет подделку со статусом 401, даже не глядя на содержимое. Обратите внимание на hmac.compare_digest: сравнение подписей делается через него, а не через ==, чтобы исключить тайминг-атаки, когда длина совпадения подбирается по времени ответа. Мелочь, а входит в привычку за один урок.

Логин: POST /login выдаёт токен

Регистрация есть, осталось пускать своих. Эндпоинт логина принимает пару логин-пароль, сверяет хеш и возвращает токен. За кодирование отвечает библиотека python-jose (вариант — PyJWT, API почти одинаков): она сама соберёт header, payload и подпись, как мы делали руками выше:

POST /login: проверяем пару и выдаём токен
from datetime import datetime, timedelta, timezone

from fastapi import Depends, FastAPI, HTTPException
from fastapi.security import OAuth2PasswordRequestForm
from jose import jwt  # pip install "python-jose[cryptography]"

SECRET_KEY = "..."  # из переменных окружения, об этом ниже
ALGORITHM = "HS256"
TOKEN_EXPIRE_MINUTES = 30


def create_access_token(username: str) -> str:
    expires = datetime.now(timezone.utc) + timedelta(minutes=TOKEN_EXPIRE_MINUTES)
    return jwt.encode(
        {"sub": username, "exp": expires},
        SECRET_KEY,
        algorithm=ALGORITHM,
    )


@app.post("/login")
def login(form: OAuth2PasswordRequestForm = Depends(),
          db: Session = Depends(get_db)):
    user = db.scalars(
        select(User).where(User.username == form.username)
    ).first()
    if user is None or not verify_password(form.password, user.password_hash):
        raise HTTPException(status_code=401, detail="Неверный логин или пароль")
    return {
        "access_token": create_access_token(user.username),
        "token_type": "bearer",
    }
Вывод
{"access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJvbGdhIiwiZXhwIjoxNzUyNjAzNjAwfQ.YN2mB884Fyxz7P_tkwzP0OxUOLJ_4wXfpPX5LnKBo8c", "token_type": "bearer"}
Ответ на POST /login с верной парой: access_token и token_type bearer. У вас токен будет другим — подпись зависит от SECRET_KEY и времени выдачи. Неверная пара получает 401, причём формулировка «неверный логин или пароль» не раскрывает, что именно не подошло.

OAuth2PasswordRequestForm читает данные из form-data с полями username и password — тот же формат, что у обычной HTML-формы. Это не случайность: именно этот формат ждёт кнопка Authorize в Swagger, и она сама сходила бы в tokenUrl за токеном. Важна и деталь с exp: срок жизни вшит в токен, поэтому «выход из системы» не требует ничего стирать на сервере — истёкший токен просто перестаёт приниматься. Обратная сторона: отозвать живой токен до истечения сервер не может, об этом — в FAQ.

get_current_user: закрываем эндпоинты зависимостью

Последний кирпич — зависимость, которая из токена восстанавливает пользователя. Строится она слоями из тех же Depends: сначала OAuth2PasswordBearer достаёт заголовок Authorization, затем get_current_user проверяет подпись и срок, ищет пользователя в базе, и уже он подставляется в эндпоинт:

Защищённый эндпоинт /me с зависимостью от токена
from fastapi.security import OAuth2PasswordBearer
from jose import JWTError, jwt

oauth2_scheme = OAuth2PasswordBearer(tokenUrl="login")


def get_current_user(token: str = Depends(oauth2_scheme),
                     db: Session = Depends(get_db)) -> User:
    error = HTTPException(
        status_code=401,
        detail="Недействительный токен",
        headers={"WWW-Authenticate": "Bearer"},
    )
    try:
        payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
    except JWTError:
        raise error  # кривая подпись, истёкший exp, битый base64
    username = payload.get("sub")
    if username is None:
        raise error
    user = db.scalars(
        select(User).where(User.username == username)
    ).first()
    if user is None:
        raise error
    return user


@app.get("/me")
def me(current_user: User = Depends(get_current_user)) -> dict:
    return {"username": current_user.username}
Вывод
{"username": "olga"}
Ответ на GET /me с заголовком Authorization: Bearer eyJhbGciOi...: зависимость распаковала токен и нашла olga. Без заголовка OAuth2PasswordBearer отвечает 401 с телом {"detail": "Not authenticated"} — до тела функции дело не доходит.

Смотрите, как аккуратно легли слои: эндпоинт /me вообще не знает про токены — он получает готовый объект User и занимается своими задачами. Вся проверка живёт в get_current_user, а его можно подставить в любой эндпоинт, который надо закрыть: def create_task(payload: TaskCreate, db: Session = Depends(get_db), user: User = Depends(get_current_user)) — и POST /tasks больше не принимает анонимов. Заодно смотрите на algorithms=[ALGORITHM]: список разрешённых алгоритмов обязательно фиксируется при decode, иначе фреймворк поверит тому, что заявлено в header подделки.

Кнопка Authorize в Swagger

OAuth2PasswordBearer с tokenUrl="login" окупается сразу: в документации по адресу /docs появляется кнопка Authorize. Вводите логин и пароль — Swagger сам отправляет POST /login, запоминает токен и подставляет заголовок Authorization во все последующие запросы из интерфейса. За сорок строк аутентификации вы бесплатно получаете интерактивный вход прямо в документации API — тестировщики скажут спасибо.

SECRET_KEY: единственный секрет всего замка

Вся подпись держится на одном секретном ключе, и относиться к нему стоит как к ключу от квартиры: не оставлять под ковриком. Правило простое — секрет приходит из окружения, а не из кода:

Генерация и подключение секретного ключа
# В терминале: случайные 32 байта в hex-кодировке
export SECRET_KEY="$(openssl rand -hex 32)"
uvicorn main:app --reload

# В main.py секрет читается из окружения, а не вшит в код
import os
SECRET_KEY = os.environ["SECRET_KEY"]
Команды выполняются локально: openssl печатает случайный ключ, export кладёт его в переменную окружения. В Docker, Railway и PowerShell механизм другой, суть одна — секрет живёт вне кода.

Прогоняем сценарий целиком и что дальше

Соберём весь путь пользователя в четыре команды: регистрация, логин, запрос с токеном и попытка пролезть без него. Обратите внимание на разницу в формате: /register принимает JSON, /login — form-data:

Сценарий в curl: от регистрации до 401
# 1. Регистрируем пользователя
curl -X POST localhost:8000/register \
  -H "Content-Type: application/json" \
  -d '{"username": "olga", "password": "s3cret-pass"}'

# 2. Логинимся и получаем токен
curl -X POST localhost:8000/login \
  -d "username=olga" -d "password=s3cret-pass"

# 3. Ходим с токеном в заголовке
curl localhost:8000/me \
  -H "Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJvbGdhIiwiZXhwIjoxNzUyNjAzNjAwfQ.YN2mB884Fyxz7P_tkwzP0OxUOLJ_4wXfpPX5LnKBo8c"

# 4. Тот же запрос без заголовка
curl localhost:8000/me
Вывод
{"username": "olga"}
{"access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJvbGdhIiwiZXhwIjoxNzUyNjAzNjAwfQ.YN2mB884Fyxz7P_tkwzP0OxUOLJ_4wXfpPX5LnKBo8c", "token_type": "bearer"}
{"username": "olga"}
{"detail": "Not authenticated"}
Сетевые команды в песочнице не выполняются — прогоняйте сценарий рядом с uvicorn или через Swagger. Последний ответ — дефолтный 401 от OAuth2PasswordBearer: заголовка нет.
  1. Пароль хранится только в виде хеша с солью; для продакшена — bcrypt или argon2, для песочницы — hashlib.
  2. Токен — это header.payload.signature, подпись HMAC-SHA256 секретным ключом сервера.
  3. Payload читается кем угодно, но меняется ни кем: подпись ловит подмену, exp ограничивает срок жизни.
  4. SECRET_KEY — только из переменных окружения, никогда из кода.
  5. Защита эндпоинта — один параметр: user: User = Depends(get_current_user).
  6. 401 — не представился, 403 — представился, но нельзя.

Чек-лист короткий, но за ним стоит целый замок: аноним получает 401 до того, как его код вообще выполнится. Задание на сейчас: закройте зависимостью get_current_user эндпоинт DELETE /tasks/{task_id} из седьмого урока и решите вопрос по-честному: задачи удаляет только тот, кто их создал. Подсказка: в таблицу tasks понадобится колонка user_id, а в get_current_user она уже почти есть.

В девятом уроке наш сервис впервые проверит сам себя: TestClient и pytest прогонят регистрацию, логин, запрос без токена и CRUD с базой — и 401 станет не страшной ошибкой, а зелёным assert'ом. А в финальном проекте десятого урока всё это соберётся в один сервис заметок: SQLite, JWT, теги и Swagger-контракт в придачу. Замок готов — ступени, которые остались, собраны в самоучителе FastAPI: тесты и финальный проект.

Что выведет код?

Сначала предскажи ответ в голове — это главный навык программиста.

import hashlib

def pw_hash(password: str, salt: str) -> str:
    return hashlib.sha256((salt + password).encode()).hexdigest()

a = pw_hash("qwerty", "salt1")
b = pw_hash("qwerty", "salt2")
print(a == b)
token = "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJvbGdhIn0.zT9kqN2v7wXqP0mH"
print(len(token.split(".")), token.split(".")[1])
Проверь себя
0 / 5

1. Что именно гарантирует подпись JWT-токена?

2. Как правильно хранить пароль пользователя в базе?

3. Клиент вызвал защищённый эндпоинт без заголовка Authorization. Что произойдёт?

4. Кто может прочитать содержимое payload перехваченного токена?

5. Пользователь вошёл, но обычной ролью вызвал админский эндпоинт. Какой статус ответить?

Карточки терминов
Запомнено: 0 / 6
Практика

Соберите мини-JWT своими руками — то же, что делает python-jose. Функция make_token(payload, secret) собирает header {"alg": "HS256", "typ": "JWT"} и payload в base64url (без знаков =), подписывает строку header.payload через hmac-sha256 (hex-подпись) и склеивает токен. Функция verify_token(token, secret) проверяет подпись (сравнение через hmac.compare_digest) и возвращает False для токена не из трёх частей. Выпустите токен для {"sub": "olga"} с секретом my-secret и проверьте его: с верным секретом, с неверным и с припиской .hack в конце.

practice.py
Вопросы и ответы по уроку

Как сделать авторизацию по JWT в FastAPI?

Три шага: POST /register с хешированием пароля (bcrypt или argon2), POST /login с проверкой пары и выдачей токена через python-jose или PyJWT, зависимость get_current_user на базе OAuth2PasswordBearer, которая декодирует токен и возвращает пользователя. Закрываемые эндпоинты получают параметр user: User = Depends(get_current_user).

Чем JWT отличается от сессий на куках?

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

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

Варианты — память приложения, localStorage или httpOnly-куки. localStorage удобен, но уязвим к XSS-скриптам; самый безопасный путь — короткоживущий access-токен в памяти плюс refresh-токен в httpOnly-куке, которым клиент тихо обновляет доступ. Для учебного проекта достаточно переменной в памяти.

Что происходит, когда срок жизни JWT истекает?

Поле exp перестаёт проходить проверку, jwt.decode бросает ExpiredSignatureError (наследник JWTError), а зависимость get_current_user отвечает 401 Unauthorized. Клиент ловит 401 и незаметно получает новый токен через refresh-механизм или повторный логин — пользователь обычно ничего не замечает.

Как отозвать JWT-токен до истечения срока?

Никак: сервер ничего не хранит, поэтому живой токен остаётся действительным, пока не истечёт exp — на это и рассчитан stateless-подход. Если доступ нужно отнять всем сразу, ротируйте SECRET_KEY: после смены секрета все старые токены мгновенно перестают приниматься, и «выйти из всех устройств» решается одним изменением в окружении.

Почему нельзя хранить пароли в открытом виде?

Базы утекают регулярно, и владелец утечки обязан быть уверенным, что пароли пользователей злоумышленник не прочитает. Хеш необратим: из строки в базе пароль не достать, а соль делает одинаковые пароли разных людей несравнимыми — без неё утёкшая таблица сдаётся радужным таблицам целиком. В продакшене берут bcrypt или argon2 — намеренно медленные функции, проверка которых занимает около 100 миллисекунд.

Понравился урок? Сошлитесь на него

«Секрет прямо в коде — самый частый слив новичков: коммит, push, и через несколько часов бот-сканер уже знает ваш SECRET_KEY.»

Скопируйте готовую ссылку в формате HTML, Markdown или чистый адрес и вставьте в статью на Habr, VC, Telegram-канал или свой блог — так о проекте узнают новые читатели.

TelegramVK

Похожие уроки по темам

Подобраны автоматически по пересечению тем и ключевых слов.

Проверьте знания по FastAPI

В челлендже — 20 задач по FastAPI, по 2 из каждого урока этого раздела. Формат: фрагмент кода и четыре варианта — что напечатает. После ответа — вердикт и объяснение со ссылкой на урок-источник.

Тест по FastAPI: 20 задач