Опирается на правила:
AUTH-20…AUTH-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();
}
}
Что каждый атрибут даёт:
| Атрибут | Защита |
|---|---|
HttpOnly | JavaScript не может читать (document.cookie не возвращает). Защита от XSS. |
Secure | Cookie отправляется только over HTTPS. Защита от network sniff. |
SameSite=Lax | Cookie не отправляется на cross-site POST (CSRF protection). На cross-site GET — отправляется (acceptable trade-off для UX). |
Path=/auth/refresh | Refresh-cookie доступен только для refresh-endpoint, не для каждого API-вызова. |
maxAge | Auto-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 в localStorage | AUTH-20 | HttpOnly cookie |
Cookie без HttpOnly | AUTH-20 | JS доступ закрыт |
Cookie без Secure | AUTH-20 | HTTPS only |
Cookie без SameSite | AUTH-20 | минимум Lax |
| Refresh без rotation | AUTH-21 | каждый refresh = новый RT, старый invalid |
| Не invalidate цепочку при reuse RT | AUTH-21 | invalidate всё, force re-login |
Refresh-cookie доступен на / (не /auth/refresh) | AUTH-20 | restrict path |
maxAge отсутствует — session cookie | AUTH-20 | explicit expiry |
Cookie без Path restriction | AUTH-20 | path для refresh — только refresh-endpoint |
Куда дальше
- Auth → раздел 9. Хранение токенов на клиенте — нормативные формулировки.
- JWT validation — backend validation tokens.
- PII и секреты — токены = secret, не logging.
- Service-to-service — S2S не использует HTTP cookies.
- Где какая проверка — Gateway получает cookie, конвертирует в Bearer.
- REST API → security headers — CORS, CSP.