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

Важно знать

  • HttpOnly + Secure + SameSite=Lax cookie — единственный safe-вариант хранения tokens в браузере.
  • localStorage запрещён — XSS-уязвимость: любой скрипт на странице читает.
  • Refresh-токены с rotation — при каждом обновлении старый инвалидируется.
  • Повторное использование старого refresh-token = компрометация → инвалидируется вся цепочка user.
  • BFF pattern: server-side хранит токены, клиент работает с session-cookie.
  • SameSite=Lax — защита от CSRF на cross-site GET.
  • Secure — HTTPS only, чтобы cookie не утекал через plaintext.
  • HttpOnly — недоступно JavaScript, защита от XSS.

Раздел информативный для backend-команды UCP (frontend живёт по другим правилам), но важный: backend настраивает session-cookies, refresh endpoints, CORS — ошибка здесь сразу влияет на security всего frontend.

localStorage — запрещён

AUTH-20: классический soft-spot SPA.

// КАТАСТРОФА — JWT в localStorage
localStorage.setItem('token', accessToken);

Что ломается:

  • Любой скрипт на странице (включая третьи стороны, npm-зависимости с malicious code, XSS-инъекции) читает localStorage.
  • Один <script src="cdn.malicious.com/x.js"> или одна XSS — все токены уходят на сервер атакёра.
  • Token persistent в браузере — даже после закрытия вкладки.

Правильно — HttpOnly cookie.

HttpOnly + Secure + SameSite=Lax

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

@RestController
@RequiredArgsConstructor
public class AuthController {

    private final OAuth2AuthorizationService authService;

    @PostMapping("/auth/login")
    public ResponseEntity<Void> login(@RequestBody @Valid LoginRequest req, HttpServletResponse resp) {
        var tokens = authService.login(req.username(), req.password());

        var accessCookie = ResponseCookie.from("access_token", tokens.accessToken())
            .httpOnly(true)
            .secure(true)
            .sameSite("Lax")
            .path("/")
            .maxAge(Duration.ofMinutes(15))
            .build();

        var refreshCookie = ResponseCookie.from("refresh_token", tokens.refreshToken())
            .httpOnly(true)
            .secure(true)
            .sameSite("Lax")
            .path("/auth/refresh")
            .maxAge(Duration.ofDays(7))
            .build();

        resp.addHeader(HttpHeaders.SET_COOKIE, accessCookie.toString());
        resp.addHeader(HttpHeaders.SET_COOKIE, refreshCookie.toString());
        return ResponseEntity.noContent().build();
    }
}

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

АтрибутЗащита
HttpOnlyJavaScript не может читать (document.cookie не возвращает). Защита от XSS.
SecureCookie отправляется только over HTTPS. Защита от network sniff.
SameSite=LaxCookie не отправляется на cross-site POST (CSRF protection). На cross-site GET — отправляется (acceptable trade-off для UX).
Path=/auth/refreshRefresh-cookie доступен только для refresh-endpoint, не для каждого API-вызова.
maxAgeAuto-expire после N секунд.

SameSite=Strict ещё строже (не отправляется даже на cross-site GET), но ломает «открыл ссылку в новой вкладке — попал на страницу, не залогинен». Для большинства приложений Lax — правильный компромисс.

BFF pattern

Альтернатива «JWT-в-cookie» — BFF (Backend-for-Frontend) хранит токены на server-side, клиент работает с session-cookie.

Browser (cookie: SESSION=abc123)
    ↓
BFF (session store: abc123 → { accessToken: ..., refreshToken: ... })
    ↓ (BFF добавляет Authorization: Bearer)
API services

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

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

Недостатки:

  • Server-side state (session store: Redis).
  • BFF — отдельный сервис, дополнительная инфра.

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

Refresh token rotation

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

T=0    Login: returns access_token (15 min) + refresh_token RT-1 (7 days)
T=15m  POST /auth/refresh с RT-1 → returns access_token + refresh_token RT-2
        IdP: marks RT-1 as USED, invalidates
T=30m  POST /auth/refresh с RT-2 → access + RT-3
T=31m  Attacker нашёл старый RT-2 в logs → POST /auth/refresh с RT-2
        IdP: RT-2 уже USED → INVALIDATE WHOLE CHAIN
        Legitimate user logged out, must re-login

Это classic OAuth2 paradigm: refresh-token использовался → больше не валиден. Используется новый, выданный с тем же access.

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

  • IdP знает, что был выдан другой токен этой цепочки.
  • Это либо attacker (украл старый из logs), либо легитимный клиент (race condition при rapid refresh).
  • Conservative response — invalidate всю цепочку, force re-login.
  • Это причиняет неудобство legitimate user, но защищает от token theft.

Spring Security при использовании Authorization Code Flow с PKCE автоматически реализует rotation если IdP поддерживает (Keycloak — да).

CSRF protection

С HttpOnly cookie сохраняется риск CSRF (Cross-Site Request Forgery): malicious site делает POST /api/orders/cancel?id=42 на нашу страницу, браузер автоматически прикладывает cookie.

Защита:

  • SameSite=Lax на cookie — браузер не отправит на cross-site POST.
  • CSRF-token в header (X-XSRF-TOKEN) для critical actions — Spring Security имеет CookieCsrfTokenRepository.
http.csrf(csrf -> csrf
    .csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse())
    .csrfTokenRequestHandler(new SpaCsrfTokenRequestHandler()));

CSRF-token cookie без HttpOnly (frontend читает и отправляет в header). Это double-submit pattern — attacker не может прочитать cookie (SameSite=Lax), значит не знает CSRF-token, значит не может отправить правильный header.

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

АнтипаттернПравилоЧто взамен
JWT в localStorageAUTH-20HttpOnly cookie
Cookie без HttpOnlyAUTH-20JS доступ закрыт
Cookie без SecureAUTH-20HTTPS only
Cookie без SameSiteAUTH-20минимум Lax
Refresh без rotationAUTH-21каждый refresh = новый RT, старый invalid
Не invalidate цепочку при reuse RTAUTH-21invalidate всё, force re-login
Refresh-cookie доступен на / (не /auth/refresh)AUTH-20restrict path
maxAge отсутствует — session cookieAUTH-20explicit expiry
Cookie без Path restrictionAUTH-20path для refresh — только refresh-endpoint

Куда дальше