Перейти к содержимому
ГоМастер

Поиск по урокам

Найдите урок, курс, термин или раздел

Урок 14 из 25 ~18 мин +20 XP

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

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

Содержание урока · 9 разделов

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

Типичная ошибка: 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, чтобы кука уходила только на него.

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

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

КритерийlocalStoragehttpOnly 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-токен живёт в переменной 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);
// что увидим в консоли?

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

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

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

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

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

Строки

Ваша программа

Нажимайте на строки слева, чтобы собрать программу

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

Термин

localStorage

нажмите, чтобы перевернуть
Определение

Словарь браузера по origin: переживает перезагрузки, читается любым скриптом страницы. Токен оттуда кладут в Authorization руками — и XSS читает его тем же getItem.

1 / 5

Хранение токена — первая половина клиентской истории; вторая — как именно браузер его отправляет и что происходит, когда сессию надо погасить. В уроке про отзыв токенов разберём 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 и сравните поведение.

Личные заметкиПусто
Хранятся только в вашем браузере (JWT · Где хранить токен на клиенте)

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

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

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

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

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

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

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

Термины урока

Ключевые понятия из этого урока — короткие определения в глоссарии. Наведите на термин, чтобы вспомнить, откройте карточку для подробностей и ссылки на практику.

Следующая ступень учебного пути

Telegram25 уроков~12 чновичок25 уроков уже доступны

Telegram-боты на Go: от эха до полноценного сервиса

Учимся писать Telegram-ботов на Go: токен BotFather, команды, клавиатуры, состояния диалога, медиа, рассылки, webhook и деплой. 25 уроков с кодом.

  • Создавать бота через BotFather и хранить токен безопасно
  • Получать обновления через long polling и webhook
  • Отвечать сообщениями, командами и клавиатурами
  • Строить многошаговые диалоги с сохранением состояния