Опирается на правила:
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=True | JavaScript не видит cookie через document.cookie. Защита от XSS. |
secure=True | Cookie отправляется только по 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_age | Auto-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.
CSRF и HttpOnly cookie
С 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), не знает значение токена, не может сформировать корректный заголовок.
Логаут — явное удаление cookie
# 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 в localStorage | AUTH-20 | response.set_cookie(httponly=True, ...) |
httponly=False для токена | AUTH-20 | httponly=True — JS не видит |
secure=False в production | AUTH-20 | secure=True — только HTTPS |
samesite не указан | AUTH-20 | минимум "lax" |
path="/" для refresh-cookie | AUTH-20 | path="/auth/refresh" — только refresh-endpoint |
max_age отсутствует | AUTH-20 | явный срок истечения |
| Refresh без rotation | AUTH-21 | каждый refresh → новый RT, старый инвалидирован |
| Не инвалидировать цепочку при reuse RT | AUTH-21 | инвалидировать всё, force re-login |
Cookie(alias=...) читает refresh вне /auth/refresh | AUTH-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.