Опирается на правила: AUTH-20, AUTH-21 из Auth Patterns → раздел 9. Хранение токенов на клиенте.

Важно знать

  • HttpOnly + Secure + SameSite=Lax cookie — единственный допустимый способ хранения tokens в браузере.
  • localStorage запрещён (AUTH-20): любой скрипт на странице читает его — XSS достаточно одного тега.
  • Refresh-токены с rotation (AUTH-21): каждый POST /auth/refresh выдаёт новый RT, старый инвалидируется.
  • Повторное использование старого RT = признак компрометации → инвалидируется вся цепочка пользователя.
  • FastAPI выставляет cookie через Response.set_cookie или responses.JSONResponse с явным Set-Cookie.
  • BFF pattern: FastAPI-сервис хранит tokens на стороне сервера, клиент работает с session-cookie.
  • Path для refresh-cookie ограничивается только refresh-endpoint — уменьшает поверхность атаки.
  • Secure=True обязателен в production; в локальном dev без HTTPS — только через secure=False в test-конфиге.

Раздел информативный для backend-команды UCP: frontend живёт по своим правилам, но FastAPI-сервис отвечает за Set-Cookie, refresh-endpoint и CORS — ошибка здесь напрямую влияет на security всего SPA.

localStorage — запрещён

AUTH-20: классическая слабая точка SPA.

// ЗАПРЕЩЕНО — JWT в localStorage
localStorage.setItem('access_token', accessToken);

Почему это ломается:

  • Любой <script> на странице — сторонняя аналитика, npm-пакет с уязвимостью, XSS-инъекция — читает localStorage.getItem('access_token').
  • Один факт XSS → все токены пользователя уходят к атакёру.
  • Token остаётся в браузере после закрытия вкладки.

Правильно — HttpOnly cookie, который JavaScript вообще не видит.

HttpOnly + Secure + SameSite=Lax через FastAPI

AUTH-20: тройка обязательна.

# adapters/in/http/auth_router.py
from datetime import timedelta
from fastapi import APIRouter, Response
from application.use_cases.login import LoginUseCase, LoginCommand
from adapters.in.http.schemas import LoginRequest

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

ACCESS_MAX_AGE = int(timedelta(minutes=15).total_seconds())
REFRESH_MAX_AGE = int(timedelta(days=7).total_seconds())


@router.post("/login", status_code=204)
async def login(req: LoginRequest, response: Response, use_case: LoginUseCase):
    tokens = await use_case.execute(LoginCommand(username=req.username, password=req.password))

    response.set_cookie(
        key="access_token",
        value=tokens.access_token,
        httponly=True,
        secure=True,
        samesite="lax",
        path="/",
        max_age=ACCESS_MAX_AGE,
    )
    response.set_cookie(
        key="refresh_token",
        value=tokens.refresh_token,
        httponly=True,
        secure=True,
        samesite="lax",
        path="/auth/refresh",
        max_age=REFRESH_MAX_AGE,
    )

Что даёт каждый атрибут:

АтрибутЗащита
httponly=TrueJavaScript не видит cookie через document.cookie. Защита от XSS.
secure=TrueCookie отправляется только по HTTPS. Защита от network sniff.
samesite="lax"Cookie не отправляется на cross-site POST (CSRF). На cross-site GET — отправляется (допустимый trade-off для UX).
path="/auth/refresh"Refresh-cookie доступен только refresh-endpoint, не каждому API-запросу.
max_ageAuto-expire через N секунд.

samesite="strict" ещё строже: не отправляется даже на cross-site GET. Для большинства приложений "lax" — правильный компромисс: пользователь открыл ссылку в новой вкладке и остаётся залогиненным.

BFF pattern

Альтернатива «JWT-в-cookie» — FastAPI-сервис как BFF хранит tokens server-side, клиент работает только с session-cookie.

Browser (cookie: SESSION=abc123)
    ↓
FastAPI BFF (Redis: abc123 → { access_token: ..., refresh_token: ... })
    ↓  (BFF добавляет Authorization: Bearer ...)
Downstream API services
# adapters/in/http/bff_auth_router.py
import secrets
from fastapi import APIRouter, Response, Request
from adapters.out.session.redis_session_store import RedisSessionStore
from application.use_cases.login import LoginUseCase, LoginCommand
from adapters.in.http.schemas import LoginRequest

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

SESSION_MAX_AGE = 60 * 60 * 24 * 7  # 7 дней


@router.post("/login", status_code=204)
async def bff_login(
    req: LoginRequest,
    response: Response,
    use_case: LoginUseCase,
    session_store: RedisSessionStore,
):
    tokens = await use_case.execute(LoginCommand(username=req.username, password=req.password))
    session_id = secrets.token_urlsafe(32)
    await session_store.put(session_id, tokens, ttl=SESSION_MAX_AGE)

    response.set_cookie(
        key="SESSION",
        value=session_id,
        httponly=True,
        secure=True,
        samesite="lax",
        path="/",
        max_age=SESSION_MAX_AGE,
    )

Преимущества BFF:

  • Tokens никогда не попадают в браузер.
  • BFF централизованно обрабатывает refresh-flow.
  • Force-logout, ограничение сессий — централизованно.

Недостатки:

  • Серверное состояние (Redis как session store).
  • BFF — отдельный сервис, дополнительная инфра.

Для большинства UCP-сервисов применяется JWT-в-cookie (stateless), но BFF — корректный паттерн для сложных SPA с долгими сессиями.

Refresh token rotation

AUTH-21: rotation обязателен.

# adapters/in/http/auth_router.py
from fastapi import APIRouter, Cookie, Response, HTTPException
from application.use_cases.refresh_tokens import RefreshTokensUseCase, RefreshCommand

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


@router.post("/refresh", status_code=204)
async def refresh(
    response: Response,
    use_case: RefreshTokensUseCase,
    refresh_token: str | None = Cookie(default=None),
):
    if not refresh_token:
        raise HTTPException(status_code=401, detail="no refresh token")

    tokens = await use_case.execute(RefreshCommand(refresh_token=refresh_token))

    response.set_cookie(
        key="access_token",
        value=tokens.access_token,
        httponly=True,
        secure=True,
        samesite="lax",
        path="/",
        max_age=ACCESS_MAX_AGE,
    )
    response.set_cookie(
        key="refresh_token",
        value=tokens.refresh_token,
        httponly=True,
        secure=True,
        samesite="lax",
        path="/auth/refresh",
        max_age=REFRESH_MAX_AGE,
    )

Сценарий rotation:

T=0    Login → access_token (15 min) + RT-1 (7 days)
T=15m  POST /auth/refresh с RT-1 → access_token + RT-2
        IdP: RT-1 помечен как USED, инвалидирован
T=30m  POST /auth/refresh с RT-2 → access_token + RT-3
T=31m  Атакёр нашёл RT-2 в логах → POST /auth/refresh с RT-2
        IdP: RT-2 уже USED → инвалидировать всю цепочку
        Легитимный пользователь выходит из системы, требуется повторный вход

При повторном использовании старого RT:

  • IdP знает, что был выдан следующий токен этой цепочки.
  • Либо атакёр (украл RT из логов), либо легитимный клиент (race condition при быстром refresh).
  • Корректный ответ — инвалидировать всю цепочку, force re-login.
  • Причиняет неудобство легитимному пользователю, но защищает от кражи токена.

RefreshTokensUseCase делегирует логику в IdP (Keycloak) или реализует rotation самостоятельно через хранилище RT с флагом used.

С HttpOnly cookie остаётся риск CSRF: вредоносный сайт делает POST /api/orders/cancel?id=42, браузер автоматически прикладывает cookie.

Защита в FastAPI:

# adapters/in/http/middleware.py
from starlette.middleware.base import BaseHTTPMiddleware
from starlette.requests import Request
from starlette.responses import Response
import secrets

CSRF_SAFE_METHODS = {"GET", "HEAD", "OPTIONS"}
CSRF_COOKIE_NAME = "csrftoken"
CSRF_HEADER_NAME = "x-csrftoken"


class CsrfMiddleware(BaseHTTPMiddleware):
    async def dispatch(self, request: Request, call_next):
        if request.method not in CSRF_SAFE_METHODS:
            cookie_token = request.cookies.get(CSRF_COOKIE_NAME)
            header_token = request.headers.get(CSRF_HEADER_NAME)
            if not cookie_token or cookie_token != header_token:
                return Response(status_code=403, content="csrf validation failed")
        response = await call_next(request)
        if CSRF_COOKIE_NAME not in request.cookies:
            response.set_cookie(
                key=CSRF_COOKIE_NAME,
                value=secrets.token_urlsafe(32),
                httponly=False,
                samesite="lax",
                secure=True,
            )
        return response

CSRF-cookie без httponly — frontend читает и отправляет в заголовке x-csrftoken. Double-submit pattern: атакёр не может прочитать cookie из другого origin (SameSite=Lax), не знает значение токена, не может сформировать корректный заголовок.

# adapters/in/http/auth_router.py
@router.post("/logout", status_code=204)
async def logout(response: Response):
    response.delete_cookie(key="access_token", path="/")
    response.delete_cookie(key="refresh_token", path="/auth/refresh")

delete_cookie устанавливает max_age=0 и expires в прошлое — браузер удаляет cookie немедленно.

Что запрещено

АнтипаттернПравилоЧто взамен
JWT в localStorageAUTH-20response.set_cookie(httponly=True, ...)
httponly=False для токенаAUTH-20httponly=True — JS не видит
secure=False в productionAUTH-20secure=True — только HTTPS
samesite не указанAUTH-20минимум "lax"
path="/" для refresh-cookieAUTH-20path="/auth/refresh" — только refresh-endpoint
max_age отсутствуетAUTH-20явный срок истечения
Refresh без rotationAUTH-21каждый refresh → новый RT, старый инвалидирован
Не инвалидировать цепочку при reuse RTAUTH-21инвалидировать всё, force re-login
Cookie(alias=...) читает refresh вне /auth/refreshAUTH-20ограничить path на уровне set_cookie

Куда дальше

  • JWT validation — валидация входящего токена через PyJWKClient + PyJWT.
  • PII и секреты — токены = секреты, не попадают в логи.
  • Service-to-service — межсервисный трафик не использует HTTP-cookies.
  • Где какая проверка — Gateway получает cookie, конвертирует в Bearer.
  • ABAC: владение ресурсом — проверка owner-id в Handler.
  • RBAC: роли — Depends(require_roles(...)) на каждом endpoint.
  • Идемпотентность — money-команды требуют Idempotency-Key.
  • Аудит admin-команд — каждое admin-действие в *_audit_log.