Содержание урока · 9 разделов
- localStorage vs cookie: как работают два лагеря
- Типичная ошибка: sessionStorage как компромисс
- CSRF: цена автоматической отправки
- localStorage против httpOnly cookie
- Гибрид: access в памяти, refresh в cookie
- Кейс: гибрид без refresh на старте — логаут каждую перезагрузку
- Песочница урока
- Коротко: что запомнить
- Вопросы и ответы
Сервер выдал токен — и браузер должен его где-то держать между запросами. Выбор места — 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. 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 с сервера
GOhttp.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, чтобы кука уходила только на него.
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, здесь важна лишь цена выбора: закрыв XSS-кражу cookie, вы берёте на себя задачу CSRF.
localStorage против httpOnly cookie#
| Критерий | localStorage | httpOnly cookie |
|---|---|---|
| Кража через XSS | да: читается любым скриптом страницы | нет: HttpOnly скрывает значение |
| CSRF | не применим: заголовок ставит сам фронтенд | да: браузер отправляет сам; лечится SameSite и CSRF-токеном |
| Как попадает в запрос | заголовком Authorization вручную | прикладывается браузером автоматически |
| Жизнь между сессиями | переживает перезагрузки, чистится вручную | задаётся MaxAge и Expires |
| Доступ между поддоменами | только свой origin | настраивается атрибутом Domain |
Сервер принимает оба варианта
GOfunc 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-токены. Цена — один дополнительный запрос при старте и чуть более хитрый фронтенд; для сервиса, где сессии стоят денег, это дёшево.
Кейс: гибрид без refresh на старте — логаут каждую перезагрузку#
Внедрение гибридной схемы, после которого команда откатывалась к вечеру: access убрали в переменную, refresh — в httpOnly cookie, а на загрузку страницы забыли повесить вызов refresh. Симптом получился коварный: вход работал, клики внутри SPA работали, но F5 или повторное открытие вкладки выбрасывали человека на логин — access потерян, восстанавливать некому. Пользователи формулировали это как «ваш сайт постоянно разлогинивает», обращений в поддержку набралось за день больше, чем за месяц. Лечение — один запрос при старте: до первого рендера защищённых данных фронтенд вызывает refresh-эндпоинт, cookie прикладывается сама, новый access возвращается в память. По шагам этот сценарий разложен в уроке про жизненный цикл сессии. Мораль: гибрид — это не только места хранения, но и точка восстановления на старте страницы.
Проверка: XSS против httpOnly
Что получает нападающий при XSS на странице с токеном в httpOnly cookie?
Проверка: флаги cookie
Какой флаг запрещает чтение cookie из JavaScript?
document.cookie против HttpOnly
Прочитайте код и выберите, что он выведет:
// сервер выставил:
// Set-Cookie: access=eyJ...; HttpOnly; Secure; SameSite=Lax
console.log(document.cookie);
// что увидим в консоли?Понадобится локальный Go: выставьте cookie из кода выше, проверьте curl -i на заголовок Set-Cookie и попробуйте document.cookie в devtools. Для честного CSRF-эксперимента поднимите вторую страницу на другом порту и отправьте из неё форму на ваш API: devtools покажут, что куки с SameSite Lax не уехали, а лог сервера — что запрос пришёл без куки вовсе.
Восстановите флаги cookie
Допишите три строки защищённой cookie: два флага и политику.
Соберите выставление cookie
Расставьте строки вызова в правильном порядке: от вызова до закрывающей скобки.
Строки
Ваша программа
Нажимайте на строки слева, чтобы собрать программу
Карточки: хранение токена
Хранение токена — первая половина клиентской истории; вторая — как именно браузер его отправляет и что происходит, когда сессию надо погасить. В уроке про отзыв токенов разберём denylist по jti и мгновенный logout, а в уроке про HttpOnly cookie — полный cookie-вариант сессии без localStorage вовсе. Проверка на сервере не меняется от выбранного варианта: tokenFromRequest кормит строкой тот же ParseWithClaims. Если фронтенд отделён от API доменом, к схеме добавится CORS — его механика разобрана в уроке про CORS курса chi. Выдача cookie на gin не отличается по смыслу — c.SetCookie с теми же флагами; детали в уроке про JWT в gin. Карта всех тем — на странице курса jwt-go.
Песочница урока
Редактируйте код и запускайте его прямо здесь — интерпретатор работает в браузере, ничего устанавливать не нужно. За каждый запуск — +2 XP.
Запустите код, чтобы увидеть вывод.
Коротко: что запомнить
- Хранение 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 для приложений — проверка при этом одна.
Термины урока
Ключевые понятия из этого урока — короткие определения в глоссарии. Наведите на термин, чтобы вспомнить, откройте карточку для подробностей и ссылки на практику.
Изучайте дальше
Следующая ступень учебного пути
Telegram-боты на Go: от эха до полноценного сервиса
Учимся писать Telegram-ботов на Go: токен BotFather, команды, клавиатуры, состояния диалога, медиа, рассылки, webhook и деплой. 25 уроков с кодом.
- Создавать бота через BotFather и хранить токен безопасно
- Получать обновления через long polling и webhook
- Отвечать сообщениями, командами и клавиатурами
- Строить многошаговые диалоги с сохранением состояния