После того как пользователь вошёл в систему, у браузера на руках есть access token и refresh token. Вопрос: где их хранить? Ответ от разработчика без опыта в безопасности — «в localStorage, там удобно». Ответ правильный — «только в HttpOnly cookie, иначе любой скрипт на странице может их украсть».
Разберём почему, и как это реализуется в Go.
Почему localStorage — плохая идея
localStorage и sessionStorage доступны через JavaScript: localStorage.getItem("token") работает из любого скрипта на странице. Это означает, что если на странице есть уязвимость XSS (например, через подключённую npm-зависимость или внешний виджет аналитики), злоумышленник получит токен в одну строку кода.
Cookie с атрибутом HttpOnly недоступны через document.cookie вовсе — браузер отправляет их в заголовке запроса автоматически, но JavaScript к значению не добирается.
Поэтому правило: токены в браузерных приложениях хранятся только в HttpOnly cookie. В тело ответа токен не попадает.
Три обязательных атрибута cookie
Когда сервер выдаёт токен через http.SetCookie, нужны три атрибута:
| Атрибут | Зачем |
|---|---|
HttpOnly: true | JavaScript не читает cookie. Защита от XSS. |
Secure: true | Cookie отправляется только по HTTPS. Защита от перехвата в сети. |
SameSite: Lax или Strict | Cookie не летит на сторонние сайты в POST-запросах. Защита от CSRF. |
Без любого из трёх защита неполная. Secure: true без HTTPS — cookie не отправляется (что хорошо в продакшене, но сломает локальную разработку без TLS).
Как выдать cookie после входа через OIDC
Типичная схема в BFF (Backend For Frontend): браузер отправляет authorization code на backend, backend обменивает его на токены у провайдера (Keycloak, Google, и т.д.) и кладёт их в cookie — access token и refresh token в разные cookie с разными настройками.
// adapters/in/http/bff/auth_handler.go
func (h *AuthHandler) Callback(w http.ResponseWriter, r *http.Request) {
tokens, err := h.oidc.Exchange(r.Context(), r.URL.Query().Get("code"))
if err != nil {
httperr.Write(w, r, &apperr.AuthError{Reason: "code exchange failed"})
return
}
// access token — короткоживущий, нужен на всём сайте
http.SetCookie(w, &http.Cookie{
Name: "session",
Value: tokens.AccessToken,
HttpOnly: true,
Secure: true,
SameSite: http.SameSiteLaxMode,
Path: "/",
MaxAge: int((15 * time.Minute).Seconds()),
})
// refresh token — живёт дольше, но доступен только на одном эндпоинте
http.SetCookie(w, &http.Cookie{
Name: "refresh_token",
Value: tokens.RefreshToken,
HttpOnly: true,
Secure: true,
SameSite: http.SameSiteStrictMode,
Path: "/auth/refresh",
MaxAge: int((7 * 24 * time.Hour).Seconds()),
})
http.Redirect(w, r, "/", http.StatusFound)
}
Обратите внимание на Path: "/auth/refresh" у refresh token. Это значит, что cookie с refresh token браузер пошлёт только на запросы к /auth/refresh — на все остальные эндпоинты (/orders, /profile, и т.д.) он не полетит. Это ограничивает поверхность атаки.
Refresh token rotation — почему refresh нельзя использовать дважды
Access token живёт 15 минут. Когда он истекает, браузер делает запрос к /auth/refresh с refresh token, и сервер выдаёт новую пару токенов. Вопрос: что делать со старым refresh token?
Если оставить его в силе — злоумышленник, который однажды перехватил refresh token, сможет обновлять сессию вечно. Поэтому при каждом /auth/refresh старый refresh token немедленно инвалидируется, а пользователю выдаётся новый. Это и называется rotation (ротация).
Важный момент: если кто-то пытается использовать refresh token, который уже был использован — это признак компрометации. Сервер должен отозвать всю цепочку токенов и заставить пользователя войти заново.
func (h *AuthHandler) Refresh(w http.ResponseWriter, r *http.Request) {
rtCookie, err := r.Cookie("refresh_token")
if err != nil {
httperr.Write(w, r, &apperr.AuthError{Reason: "refresh token missing"})
return
}
tokens, err := h.oidc.Refresh(r.Context(), rtCookie.Value)
if err != nil {
// ошибка обмена при валидном токене — возможная компрометация
h.tokenStore.RevokeFamily(r.Context(), rtCookie.Value)
http.SetCookie(w, clearCookie("refresh_token", "/auth/refresh"))
httperr.Write(w, r, &apperr.AuthError{Reason: "refresh failed"})
return
}
// новый access token
http.SetCookie(w, &http.Cookie{
Name: "session",
Value: tokens.AccessToken,
HttpOnly: true,
Secure: true,
SameSite: http.SameSiteLaxMode,
Path: "/",
MaxAge: int((15 * time.Minute).Seconds()),
})
// новый refresh token — старый провайдер уже пометил как использованный
http.SetCookie(w, &http.Cookie{
Name: "refresh_token",
Value: tokens.RefreshToken,
HttpOnly: true,
Secure: true,
SameSite: http.SameSiteStrictMode,
Path: "/auth/refresh",
MaxAge: int((7 * 24 * time.Hour).Seconds()),
})
}
func clearCookie(name, path string) *http.Cookie {
return &http.Cookie{
Name: name,
Value: "",
HttpOnly: true,
Secure: true,
Path: path,
MaxAge: -1,
}
}
Чтение cookie в middleware
BFF-приложение читает токен из cookie session в middleware, проверяет подпись JWT и кладёт данные пользователя в контекст запроса.
// adapters/in/http/middleware/authn.go
func AuthN(v *security.JWTValidator) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
cookie, err := r.Cookie("session")
if err != nil {
// публичные роуты пропускаем — principal в контексте не будет
next.ServeHTTP(w, r)
return
}
principal, err := v.Validate(cookie.Value)
if err != nil {
httperr.Write(w, r, &apperr.AuthError{Reason: "invalid session"})
return
}
ctx := context.WithValue(r.Context(), principalKey, principal)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
}
В chi-роутере middleware подключается ко всем роутам, а защищённые группы дополнительно проверяют роли:
r := chi.NewRouter()
r.Use(middleware.AuthN(jwtValidator))
r.Get("/auth/callback", authHandler.Callback)
r.Post("/auth/refresh", authHandler.Refresh)
r.Post("/auth/logout", authHandler.Logout)
r.Group(func(r chi.Router) {
r.Use(middleware.RequireRoles("customer"))
r.Post("/orders", orderHandler.CreateOrder)
r.Get("/orders/{id}", orderHandler.GetOrder)
})
Выход из системы — явная очистка cookie
При logout нужно сделать два действия: отозвать refresh token у провайдера и удалить оба cookie у браузера.
func (h *AuthHandler) Logout(w http.ResponseWriter, r *http.Request) {
if rt, err := r.Cookie("refresh_token"); err == nil {
_ = h.oidc.Revoke(r.Context(), rt.Value)
}
http.SetCookie(w, clearCookie("session", "/"))
http.SetCookie(w, clearCookie("refresh_token", "/auth/refresh"))
http.Redirect(w, r, "/", http.StatusFound)
}
MaxAge: -1 — это директива браузеру немедленно удалить cookie. Просто перенаправить пользователя без очистки cookie — типичная ошибка: старый токен ещё живёт до истечения MaxAge.
BFF как посредник к внутренним сервисам
BFF получает запрос от браузера с cookie, проверяет токен и затем обращается к внутренним сервисам уже с Authorization: Bearer в заголовке. Важно: для запросов к внутренним сервисам BFF использует собственные Client Credentials — токен пользователя туда не проксируется напрямую.
func (c *OrderClient) CreateOrder(ctx context.Context, cmd CreateOrderRequest) (OrderID, error) {
body, _ := json.Marshal(cmd)
req, _ := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+"/orders", bytes.NewReader(body))
tok, err := c.tokenSrc.Token() // Client Credentials, не токен пользователя
if err != nil {
return "", &apperr.GatewayError{System: "order-svc", Op: "token", Err: err}
}
req.Header.Set("Authorization", "Bearer "+tok.AccessToken)
req.Header.Set("Content-Type", "application/json")
resp, err := c.http.Do(req)
// ...
}
Частые ошибки
Токен в теле ответа. Если сервер возвращает {"access_token": "..."} в JSON — браузерный JavaScript его получит и может сохранить в localStorage. Правильно: токен выдаётся только через http.SetCookie.
Cookie без HttpOnly. JavaScript сможет читать document.cookie — теряется основная защита от XSS.
Refresh cookie на Path: "/". Тогда refresh token полетит на каждый запрос к API — хотя нужен только для обновления сессии. Ограничивайте Path: "/auth/refresh".
Refresh без ротации. Один refresh token используется многократно. Если он однажды утёк — злоумышленник обновляет сессию вечно, а вы не знаете об этом.
Нет MaxAge (session cookie). Без MaxAge cookie живёт до закрытия браузера — при следующем открытии он исчезнет, но время жизни не контролируется явно. Задавайте MaxAge всегда.
Коротко
- Хранить токены в
localStorageнельзя: любой JavaScript на странице может их прочитать через XSS. - Правильный способ —
http.SetCookieсHttpOnly: true,Secure: true,SameSite. - Access token и refresh token — в разные cookie: refresh ограничивается
Path: "/auth/refresh". - При каждом
/auth/refreshстарый refresh token инвалидируется, выдаётся новый (rotation). - Попытка использовать уже использованный refresh token — признак компрометации: отзываем всю цепочку и требуем повторный вход.
- При logout отзываем refresh token у провайдера и чистим оба cookie через
MaxAge: -1. - BFF обращается к внутренним сервисам от своего имени (Client Credentials), не прокидывая токен пользователя.
Что почитать дальше
- Проверка JWT-токена в Go — как BFF и внутренние сервисы проверяют подпись токена.
- Где делается проверка — gateway читает cookie, конвертирует в Bearer; внутренний сервис cookie не видит.
- Service-to-service аутентификация — между сервисами cookie не используют; только Client Credentials или mTLS.
- RBAC: проверка ролей — как роли извлекаются из JWT и проверяются в middleware.