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

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

Разберём почему, и как это реализуется в Go.

Почему localStorage — плохая идея

localStorage и sessionStorage доступны через JavaScript: localStorage.getItem("token") работает из любого скрипта на странице. Это означает, что если на странице есть уязвимость XSS (например, через подключённую npm-зависимость или внешний виджет аналитики), злоумышленник получит токен в одну строку кода.

Cookie с атрибутом HttpOnly недоступны через document.cookie вовсе — браузер отправляет их в заголовке запроса автоматически, но JavaScript к значению не добирается.

Поэтому правило: токены в браузерных приложениях хранятся только в HttpOnly cookie. В тело ответа токен не попадает.

Когда сервер выдаёт токен через http.SetCookie, нужны три атрибута:

АтрибутЗачем
HttpOnly: trueJavaScript не читает cookie. Защита от XSS.
Secure: trueCookie отправляется только по HTTPS. Защита от перехвата в сети.
SameSite: Lax или StrictCookie не летит на сторонние сайты в POST-запросах. Защита от CSRF.

Без любого из трёх защита неполная. Secure: true без HTTPS — cookie не отправляется (что хорошо в продакшене, но сломает локальную разработку без TLS).

Типичная схема в 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,
    }
}

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

При 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), не прокидывая токен пользователя.

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