---
title: "Где хранить токен на клиенте"
description: "Хранение jwt на клиенте: localStorage vs httpOnly cookie, XSS и CSRF, SameSite и Secure, гибрид с access в памяти и refresh в cookie. Честные компромиссы."
course: "JWT-аутентификация в Go: golang-jwt и bcrypt"
url: "https://golangmaster.ru/courses/jwt-go/lessons/14-hranenie-tokena/"
published: "2026-09-19"
updated: "2026-10-02"
minutes: 18
---

# Где хранить токен на клиенте

*Курс «JWT-аутентификация в Go: golang-jwt и bcrypt», урок 14 из 25. ~18 минут. Обновлено 02.10.2026. Источник: https://golangmaster.ru/courses/jwt-go/lessons/14-hranenie-tokena/*

Хранение jwt на клиенте определяет, что сможет сделать нападающий: localStorage читается любым скриптом страницы, httpOnly cookie не читается, но отправляется сама. Разбираем localStorage vs cookie, векторы XSS и CSRF, флаги SameSite и Secure и гибридную схему, в которой access живёт в памяти, а refresh — в максимально закрытой cookie.

Сервер выдал токен — и браузер должен его где-то держать между запросами. Выбор места — localStorage, память вкладки или httpOnly cookie — определяет, какие атаки вообще возможны: хранение jwt на клиенте это не вкусовщина, а распределение риска между XSS и CSRF. localStorage читает любой скрипт на странице — он удобен и уязвим одновременно; cookie с HttpOnly скрипту не видна — она защищена от кражи скриптом, но отправляется браузером сама, чем пользуется CSRF. Третьего идеального варианта нет: память теряется при перезагрузке страницы, localStorage умирает от одной инъекции, cookie требует настройки SameSite. Задача урока — увидеть цену каждого варианта в конкретных атаках и собрать схему, которой живут продакшены: короткоживущий access там, где его не выкрасть, и refresh в закрытой cookie.

### localStorage vs cookie: как работают два лагеря

localStorage — словарь браузера, привязанный к origin: setItem сохраняет строку, getItem читает её, данные живут до явной очистки и переживают перезагрузки. Токен оттуда не уезжает сам: фронтенд кладёт его в заголовок Authorization каждого fetch — та самая схема Bearer из [урока про Authorization](/courses/jwt-go/lessons/18-avtorization-zagolovok/). Cookie — другой механизм: сервер ставит её заголовком Set-Cookie, браузер хранит и сам прикладывает к запросам на свой домен, включая AJAX и переходы по ссылкам. Разница в автоматизации и есть водораздел: localStorage отправляется руками и потому не участвует в CSRF, cookie отправляется всегда и потому нуждается в защите от подделки запросов. Второе отличие — доступность для скрипта: localStorage читается всем JS страницы, а HttpOnly закрывает cookie от document.cookie целиком. Отсюда весь дальнейший разбор: каждый вариант открыт ровно для тех атак, от которых закрыт другой.

### Вариант первый: Bearer из localStorage

```go
// после успешного логина
const data = await response.json();
localStorage.setItem("access", data.access);

// на каждый защищённый запрос
fetch("/api/me", {
  method: "GET",
  headers: {
    Authorization: "Bearer " + localStorage.getItem("access"),
  },
});
```

Классический SPA-паттерн: токен хранится в localStorage и прикладывается к каждому запросу заголовком Bearer — то, что сервер из десятого урока и ждёт. Строка собирается конкатенацией: шаблонные строки с подстановкой здесь опущены намеренно, чтобы пример работал в любой сборке. Расплата видна в следующем блоке: getItem доступен любому скрипту, попавшему на страницу.

> **Живое место: одна инъекция — и токен ушёл.** XSS на странице с токеном в localStorage — это не «возможность украсть сессию», а гарантированная: скрипт делает getItem и отправляет значение на чужой сервер одним fetch. Чужой код попадает на страницу разными путями: скомпрометированный npm-пакет, рекламный тег, уязвимость в собственном бандле. HttpOnly cookie в том же сценарии не читается: скрипт видит пустоту. Моё правило: если на странице исполняется чужой JavaScript — рекламные сети, виджеты, плагины, — localStorage не место для access-токена. Оговорка по честности: для внутреннего инструмента без сторонних скриптов и со строгой CSP localStorage терпим, и многие так живут, но это осознанный риск, а не дефолт.

### Типичная ошибка: sessionStorage как компромисс

После историй про localStorage командам хочется компромисса, и они находят sessionStorage: токен живёт только в пределах вкладки и умирает вместе с ней — выглядит аккуратнее. По вектору XSS разницы нет: любой скрипт страницы читает sessionStorage тем же getItem, и одна инъекция уносит токен так же надёжно. Меняется поведение для человека: новая вкладка и окно после перезапуска остаются без токена, и фронтенд обязан тихо переполучать его через refresh. Если этого не сделали, пользователи пишут в поддержку «приходится входить заново каждый раз», а трекер наполняется жалобами на случайные логауты. Выбирайте sessionStorage осознанно: как способ не держать токен дольше сессии вкладки, а не как защиту от кражи. От кражи скриптом защищает только невидимость — память до перезагрузки или httpOnly cookie; третьего варианта браузер не придумал.

### Вариант второй: httpOnly cookie с сервера

```go
http.SetCookie(w, &http.Cookie{
        Name:     "access",
        Value:    access,
        Path:     "/",
        HttpOnly: true,
        Secure:   true,
        SameSite: http.SameSiteLaxMode,
        MaxAge:   int((15 * time.Minute).Seconds()),
})
```

Три флага работают связкой: HttpOnly закрывает значение от document.cookie, Secure запрещает передачу по открытому HTTP, SameSite Lax держит куки при кросс-сайтовых POST — первом слое защиты от CSRF. MaxAge равен сроку access-токена: короткая сессия в cookie — не потери, а замысел. Для refresh-токена сюда добавился бы узкий Path вроде /api/auth/refresh, чтобы кука уходила только на него.

> **Не пишите Domain без нужды.** Cookie без атрибута Domain привязывается к точному хосту: api.example.com не делится ею с test-api.example.com. Явный Domain=example.com расширяет область на все поддомены разом — включая тестовые стенды и забытые сервисы. Правило: указывайте Domain, только если поддомены действительно должны читать куку, и держите отдельные имена для access и refresh, чтобы области действия не слипались.

### CSRF: цена автоматической отправки

Автоматическая отправка cookie — одновременно её сила и её уязвимость. Злой сайт может отправить форму на ваш API: браузер пользователя добросовестно приложит cookie к запросу, и сервер увидит аутентифицированную сессию, хотя решение принимал злоумышленник — это и есть CSRF. SameSite режет вектор первой строкой: Lax отправляет куки только в безопасных переходах верхнего уровня, Strict не отправляет вовсе на чужих сайтах, и для JSON API Lax обычно достаточен, потому что кросс-сайтовый POST формой не выставит нужный Content-Type. Второй слой — CSRF-токен: случайное значение, которое сервер выдаёт и требует обратно в заголовке; чужой сайт его не знает. localStorage в этой войне не участвует: фронтенд вашего домена сам решает, куда положить заголовок, и чужая форма заголовок не подставит. Полный разбор вектора и защиты — в [уроке про CSRF](/courses/jwt-go/lessons/21-csrf/), здесь важна лишь цена выбора: закрыв XSS-кражу cookie, вы берёте на себя задачу CSRF.

### localStorage против httpOnly cookie

| Критерий | localStorage | httpOnly cookie |
| --- | --- | --- |
| Кража через XSS | да: читается любым скриптом страницы | нет: HttpOnly скрывает значение |
| CSRF | не применим: заголовок ставит сам фронтенд | да: браузер отправляет сам; лечится SameSite и CSRF-токеном |
| Как попадает в запрос | заголовком Authorization вручную | прикладывается браузером автоматически |
| Жизнь между сессиями | переживает перезагрузки, чистится вручную | задаётся MaxAge и Expires |
| Доступ между поддоменами | только свой origin | настраивается атрибутом Domain |

### Сервер принимает оба варианта

```go
func tokenFromRequest(r *http.Request) (string, error) {
        if c, err := r.Cookie("access"); err == nil {
                return c.Value, nil
        }
        header := r.Header.Get("Authorization")
        const prefix = "Bearer "
        if strings.HasPrefix(header, prefix) {
                return strings.TrimPrefix(header, prefix), nil
        }
        return "", errors.New("нет токена ни в cookie, ни в заголовке")
}
```

Сервер принимает оба транспорта: cookie первым — она приходит автоматически и закрывает большинство веб-клиентов, Bearer вторым — для мобильных приложений и скриптов. Дальше строка идёт в тот же ParseWithClaims из шестого урока: транспорт меняется, проверка нет. Дуал полезен при миграции между схемами, но в долгую лучше выбрать один вариант и держать поверхность атаки узкой.

### Гибрид: access в памяти, refresh в cookie

Продакшен-схема собирает лучшее с обеих сторон. Access-токен живёт в переменной JS: с ним не работают ни CSRF, ни кража из хранилища — после перезагрузки страницы он теряется, и это нормально. Refresh-токен уезжает в HttpOnly cookie с узким Path и SameSite: скрипт его не видит, кросс-сайтовые запросы его не унесут, а на перезагрузке страницы фронтенд молча вызывает refresh и получает новый access. Итог: XSS, укравший память вкладки, получает access на пятнадцать минут, а не сессию на месяц; CSRF упирается в SameSite и узкий Path. Схема требует пары access/refresh с ротацией — она собрана в [уроке про refresh-токены](/courses/jwt-go/lessons/08-refresh-token/). Цена — один дополнительный запрос при старте и чуть более хитрый фронтенд; для сервиса, где сессии стоят денег, это дёшево.

### Кейс: гибрид без refresh на старте — логаут каждую перезагрузку

Внедрение гибридной схемы, после которого команда откатывалась к вечеру: access убрали в переменную, refresh — в httpOnly cookie, а на загрузку страницы забыли повесить вызов refresh. Симптом получился коварный: вход работал, клики внутри SPA работали, но F5 или повторное открытие вкладки выбрасывали человека на логин — access потерян, восстанавливать некому. Пользователи формулировали это как «ваш сайт постоянно разлогинивает», обращений в поддержку набралось за день больше, чем за месяц. Лечение — один запрос при старте: до первого рендера защищённых данных фронтенд вызывает refresh-эндпоинт, cookie прикладывается сама, новый access возвращается в память. По шагам этот сценарий разложен в [уроке про жизненный цикл сессии](/courses/jwt-go/lessons/19-zhiznennyj-cikl/). Мораль: гибрид — это не только места хранения, но и точка восстановления на старте страницы.

### Проверка: XSS против httpOnly

**Вопрос:** Что получает нападающий при XSS на странице с токеном в httpOnly cookie?

- Значение cookie одним вызовом document.cookie
- ✅ Ничего из значения cookie, но может выполнять запросы от имени пользователя из его браузера
- Пароль пользователя в открытом виде
- Доступ к localStorage других сайтов

HttpOnly закрывает чтение: document.cookie не покажет токен. Но XSS опаснее чтения: скрипт выполняется в браузере жертвы и может слать запросы от её имени — без знания токена, но с его эффектом. Разница с localStorage в том, что украденный токен работает и вне браузера жертвы, а httpOnly-сессия живёт, пока жив доступ к её браузеру.

### Проверка: флаги cookie

**Вопрос:** Какой флаг запрещает чтение cookie из JavaScript?

- Secure
- ✅ HttpOnly
- SameSite
- Path

HttpOnly скрывает cookie от document.cookie и любого скрипта. Secure запрещает передачу по HTTP, SameSite ограничивает кросс-сайтовую отправку, Path — область действия. Флаги комбинируются: для токена нужны HttpOnly и Secure как минимум, SameSite — третий по важности.

### document.cookie против HttpOnly

```go
// сервер выставил:
// Set-Cookie: access=eyJ...; HttpOnly; Secure; SameSite=Lax

console.log(document.cookie);
// что увидим в консоли?
```

**Что выведет программа?**

- Строку access=eyJ... с самим токеном
- ✅ Пустую строку: httpOnly-куки в document.cookie не попадают
- Только имя cookie без значения
- Ошибку безопасности из браузера

HttpOnly убирает cookie из document.cookie полностью: браузер хранит и отправляет её, но скрипту не показывает. В консоли будут только не-HttpOnly куки того же origin — например, аналитические. Это и есть защита от кражи: XSS не прочитает значение, даже если захочет.

> **Как повторить у себя.** Понадобится локальный Go: выставьте cookie из кода выше, проверьте curl -i на заголовок Set-Cookie и попробуйте document.cookie в devtools. Для честного CSRF-эксперимента поднимите вторую страницу на другом порту и отправьте из неё форму на ваш API: devtools покажут, что куки с SameSite Lax не уехали, а лог сервера — что запрос пришёл без куки вовсе.

### Восстановите флаги cookie

Допишите три строки защищённой cookie: два флага и политику.

```go
http.SetCookie(w, &http.Cookie{
        Name:     "access",
        Value:    access,
        HttpOnly: ___,
        Secure:   ___,
        SameSite: http.___,
        MaxAge:   int((15 * time.Minute).Seconds()),
})
```

Подсказка: Оба булевых флага включены явно, а для API-сессий обычно берут Lax: константа называется SameSiteLaxMode.

Правильные ответы в пропусках: `true`, `true`, `SameSiteLaxMode`

### Соберите выставление cookie

Расставьте строки вызова в правильном порядке: от вызова до закрывающей скобки.

Правильный порядок строк:

1. `http.SetCookie(w, &http.Cookie{`
2. `Name: "access",`
3. `Value: access,`
4. `HttpOnly: true,`
5. `SameSite: http.SameSiteLaxMode,`
6. `})`

### Карточки: хранение токена

- **localStorage** — Словарь браузера по origin: переживает перезагрузки, читается любым скриптом страницы. Токен оттуда кладут в Authorization руками — и XSS читает его тем же getItem.
- **httpOnly** — Флаг cookie: значение скрыто от document.cookie и скриптов. Браузер хранит и отправляет куки сам; XSS не может прочитать значение.
- **SameSite** — Политика кросс-сайтовой отправки: Lax пускает куки в безопасных переходах, Strict не отправляет на чужих сайтах. Первый слой против CSRF.
- **CSRF** — Атака чужой формой: браузер жертвы прикладывает cookie сам. Защита — SameSite плюс CSRF-токен, который знает только ваш домен.
- **Гибридная схема** — Access в переменной JS, refresh в HttpOnly cookie с узким Path и тихим обновлением. XSS получает токен на минуты, CSRF упирается в SameSite; страница стартует с вызова refresh.

Хранение токена — первая половина клиентской истории; вторая — как именно браузер его отправляет и что происходит, когда сессию надо погасить. В [уроке про отзыв токенов](/courses/jwt-go/lessons/15-otzyv-tokenov/) разберём denylist по jti и мгновенный logout, а в [уроке про HttpOnly cookie](/courses/jwt-go/lessons/16-httponly-cookie/) — полный cookie-вариант сессии без localStorage вовсе. Проверка на сервере не меняется от выбранного варианта: tokenFromRequest кормит строкой тот же ParseWithClaims. Если фронтенд отделён от API доменом, к схеме добавится CORS — его механика разобрана в [уроке про CORS курса chi](/courses/chi/lessons/14-cors/). Выдача cookie на gin не отличается по смыслу — c.SetCookie с теми же флагами; детали в [уроке про JWT в gin](/courses/gin/lessons/15-jwt/). Карта всех тем — [на странице курса jwt-go](/courses/jwt-go/).

## Коротко: что запомнить

- Хранение jwt на клиенте — распределение риска: localStorage открыт XSS, cookie открыта CSRF.
- localStorage отправляется руками заголовком Bearer и переживает перезагрузки; читается любым скриптом origin.
- HttpOnly, Secure и SameSite — три флага защищённой cookie; HttpOnly закрывает значение от скриптов.
- SameSite Lax отсекает кросс-сайтовые POST — первый слой против CSRF, второй — CSRF-токен.
- Гибрид: access в памяти вкладки, refresh в HttpOnly cookie с узким Path и тихим обновлением.
- Сервер принимает оба транспорта: cookie первым, Bearer вторым; проверка токена не меняется.

## Домашнее задание

Соберите оба варианта на своём сервисе: Bearer из localStorage и httpOnly cookie с тремя флагами. Откройте devtools, попробуйте прочитать обе куки из консоли и запишите разницу. Затем поднимите страницу на втором порту с формой на ваш API и посмотрите, как SameSite Lax отсекает кросс-сайтовый запрос; повторите с SameSite Strict и сравните поведение.

## Вопросы и ответы по уроку

### Почему не хранить access-токен в localStorage, если CSP настроена?

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

### Cookie — это же сессии, а не JWT, разве нет?

Cookie — транспорт, а не формат: в ней спокойно живёт и JWT, и случайный идентификатор сессии. Разница — в последствиях утечки и в том, кому нужно читать содержимое; сравнение двух подходов по числу ходов и точкам отказа разобрано в уроке про HttpOnly cookie.

### Что делать с мобильными приложениями?

Мобильные клиенты обычно ходят с Bearer из защищённого хранилища платформы: Keychain у iOS, EncryptedSharedPreferences у Android. Поэтому серверу полезен дуал из урока: cookie для веба, заголовок Authorization для приложений — проверка при этом одна.
