← назад к разделу

После того как пользователь вошёл в систему, сервер выдаёт ему токены — access token и refresh token. Вопрос: куда браузер их положит? Ответ на него напрямую влияет на безопасность всего приложения.

Почему localStorage — плохое место для токенов

Первая мысль при разработке SPA: сохранить токен в localStorage. Это удобно — данные живут между вкладками, их легко читать.

// Так делать нельзя
localStorage.setItem('access_token', accessToken);

Проблема в том, что localStorage доступен любому JavaScript-коду на странице. Если на сайте подключена сторонняя аналитика, рекламный скрипт или npm-пакет с уязвимостью — они могут прочитать токен так же легко, как и ваш код. Один успешный XSS-запрос, и токен уходит к злоумышленнику.

Решение — HttpOnly cookie. Браузер хранит его сам и автоматически прикладывает к каждому запросу, но JavaScript доступа к нему не имеет вообще: document.cookie его не видит. Украсть значение через XSS не получится.

FastAPI выставляет cookie через метод response.set_cookie. Для токенов нужно задать три обязательных атрибута:

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. Без этого атрибута токен может уйти по открытому соединению.
  • samesite="lax" — cookie не отправляется при межсайтовых POST-запросах. Это защищает от CSRF-атак. Значение "lax" допускает передачу при переходе по ссылке из другого сайта (например, пользователь открыл ссылку в новой вкладке и остался залогиненным). "strict" строже, но ломает такой сценарий.
  • path="/auth/refresh" у refresh-cookie — cookie уйдёт только на один конкретный endpoint, а не ко всем API-запросам. Это сужает область, где refresh token может случайно оказаться в логах или обработчике.
  • max_age — явный срок жизни. Без него cookie станет сессионным и исчезнет при закрытии браузера, но это не то же самое, что «безопасно истёк».

Refresh token rotation

Access token живёт 15 минут. Когда он истекает, клиент запрашивает новый, предъявляя refresh token. Здесь появляется важная деталь: каждый успешный refresh должен выдавать новый refresh token и аннулировать старый.

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,
    )

Зачем менять refresh token при каждом обновлении:

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

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

BFF — токены вообще не попадают в браузер

Есть более строгий подход: FastAPI-сервис выступает посредником (Backend for Frontend, BFF). Токены хранятся только на сервере — например, в Redis. Браузер получает лишь непрозрачный идентификатор сессии.

Браузер (cookie: SESSION=abc123)
    ↓
FastAPI BFF (Redis: abc123 → { access_token: ..., refresh_token: ... })
    ↓  (BFF добавляет Authorization: Bearer ...)
Нижележащие сервисы
import secrets
from fastapi import APIRouter, Response
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: токены никогда не покидают сервер, принудительный выход и ограничение числа сессий реализуются централизованно.

Минус: серверное состояние. Нужен Redis или другое хранилище сессий, BFF — отдельная инфраструктура.

Для большинства приложений достаточно JWT в HttpOnly cookie (stateless подход). BFF оправдан для сложных SPA с долгими сессиями и требованиями централизованного управления ими.

HttpOnly cookie закрывает XSS, но открывает другую щель: CSRF. Браузер автоматически прикладывает cookie к любому запросу, в том числе к запросу с вредоносного сайта. Например, сторонняя страница делает POST /api/orders/cancel?id=42 — браузер отправляет cookie, и сервер не знает, что запрос пришёл не от пользователя.

SameSite=Lax снижает этот риск: cookie не уйдёт при межсайтовом POST. Для более надёжной защиты добавляют Double Submit Cookie:

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, чтобы JavaScript мог его прочитать и отправить в заголовке x-csrftoken. Сервер сравнивает значение в cookie и в заголовке. Злоумышленник с другого сайта не может прочитать cookie из вашего домена, поэтому не знает правильного значения и не может сформировать корректный заголовок.

При logout недостаточно просто перестать отправлять cookie. Нужно явно их удалить:

@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 и дату истечения в прошлое — браузер немедленно удаляет cookie.

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

JWT в localStorage. Любой JavaScript на странице читает localStorage. HttpOnly cookie решает эту проблему: значение недоступно из кода.

httponly=False для токена. Если токен лежит в cookie, но httponly не установлен — защита от XSS не работает. JavaScript по-прежнему читает document.cookie.

secure=False в production. Без этого атрибута браузер отправит cookie по HTTP. В локальной разработке без HTTPS secure=False допустим, в production — нет.

samesite не указан. Браузеры по умолчанию ведут себя по-разному. Явно укажите минимум "lax".

path="/" для refresh-cookie. Refresh token будет прикладываться ко всем запросам. Если endpoint логирует заголовки, токен может попасть в лог. Ограничьте path="/auth/refresh".

Refresh без rotation. Если один и тот же refresh token можно использовать многократно, его кража остаётся незамеченной. Каждое обновление должно выдавать новый токен и аннулировать старый.

Коротко

  • Хранить токены в localStorage нельзя — любой скрипт на странице читает его. Используйте HttpOnly cookie.
  • HttpOnly cookie недоступен для JavaScript: XSS не может его украсть.
  • Три обязательных атрибута: httponly=True, secure=True, samesite="lax".
  • Refresh-cookie ограничивайте path="/auth/refresh" — он не нужен всем запросам.
  • Refresh token rotation: каждый успешный /auth/refresh выдаёт новый RT и аннулирует старый.
  • Повторное использование уже потреблённого RT — признак компрометации; правильный ответ — аннулировать всю цепочку.
  • BFF-подход: токены хранятся только на сервере в Redis, браузер получает лишь идентификатор сессии.
  • При logout удаляйте cookie явно через response.delete_cookie.

Что почитать дальше

  • JWT validation в Python — проверка входящего токена через PyJWKClient + PyJWT.
  • Service-to-service аутентификация — межсервисный трафик не использует HTTP-cookies.
  • RBAC: роли — Depends(require_roles(...)) на каждом endpoint.