После урока про хранение токена логичный вопрос: если localStorage опасен, а память теряется при перезагрузке, почему бы не отдать всю транспортную работу браузеру? Для классического веб-приложения ответ именно такой: httpOnly cookie — старейший рабочий способ носить сессию. Сервер выпускает токен, кладёт его в куку с правильными флагами, и дальше транспорт живёт без единой строки на фронтенде: обновление страницы не роняет сессию, новая вкладка её получает, а скрипт не может прочитать значение, чтобы унести на чужой сервер. Автоматика — одновременно и расплата: кука уезжает с каждым запросом на домен без спроса, чем пользуются CSRF-атаки, незнакомые localStorage-схеме. В этом уроке соберём cookie сессии go-хендлером с тремя флагами, посмотрим, что меняется в серверном middleware, и правильно потушим сессию при выходе. Разговор опирается на материал уроков с шестого по пятнадцатый: транспорт здесь новый, а криптография — та же.
Как устроена httponly cookie jwt-сессия#
Механика из трёх участников. Сервер на логине отвечает заголовком Set-Cookie со значением токена и атрибутами HttpOnly, Secure, SameSite — браузер сохраняет куку, привязав её к домену. Дальше браузер сам прикладывает её к каждому запросу на этот домен заголовком Cookie — и к AJAX, и к переходам по ссылкам, без какого-либо кода на клиенте. HttpOnly — единственный атрибут, про который браузер молчит для JavaScript: кука хранится и отправляется, но в document.cookie не видна. Серверной стороне всё равно, каким транспортом приехал токен: middleware достаёт строку вызовом r.Cookie и отдаёт её в тот же ParseWithClaims из шестого урока. Меняется транспорт — кука вместо заголовка Authorization из урока про middleware — а проверка подписи, exp и ролей остаётся ровно той же. JWT внутри куки — всё тот же токен, просто в другой обёртке. Отсюда практический вывод: перевод сервиса на куки не трогает выпуск токенов и не ослабляет проверку — меняется только то, откуда middleware читает строку, и это ограничивает правки одним файлом.
Выдача cookie сессии go-хендлером
GOfunc handleLogin(w http.ResponseWriter, r *http.Request) {
var in Credentials
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
http.Error(w, "битый JSON", http.StatusBadRequest)
return
}
if bcrypt.CompareHashAndPassword(users.Hash(in.Email), []byte(in.Password)) != nil {
http.Error(w, "неверные данные входа", http.StatusUnauthorized)
return
}
token, err := IssueAccess(in.Email, "user")
if err != nil {
http.Error(w, "сервер не смог выпустить токен", http.StatusInternalServerError)
return
}
http.SetCookie(w, &http.Cookie{
Name: "access",
Value: token,
Path: "/",
HttpOnly: true,
Secure: true,
SameSite: http.SameSiteLaxMode,
MaxAge: int((15 * time.Minute).Seconds()),
})
w.WriteHeader(http.StatusOK)
}Сессия собирается из трёх частей: bcrypt-сравнение пароля из девятого урока, выпуск access-токена и http.SetCookie. Флаги идут связкой: HttpOnly закрывает значение от document.cookie, Secure запрещает передачу по открытому HTTP, SameSite Lax держит куку дома при кросс-сайтовых POST. MaxAge совпадает со сроком access-токена: куке нечего делать дольше своей сессии. Тела в ответе можно не писать — фронту достаточно статуса 200.
Флаги SameSite и Secure: что закрывает каждый#
Secure разрешает отправку только по HTTPS: кука, случайно поставленная на открытый порт, не уедет по сети в открытом виде. SameSite управляет кросс-сайтовыми запросами: Lax пускает куку в безопасных переходах верхнего уровня — клик по ссылке, — но не в кросс-сайтовых POST-формах, которыми и живёт CSRF; Strict не отправляет её с чужого сайта вовсе; None отправляет везде и требует Secure, без него браузеры куку отбросят. Path сужает область: кука с Path=/api/auth/refresh приедет только на один эндпоинт, а не на весь API. Для access-куки обычного сайта рабочая связка — HttpOnly, Secure, Lax и Path=/; refresh-куке вместо корня берут узкий маршрут. SameSite None оставьте для случаев, когда фронтенд и API физически на разных доменах: тогда CSRF-токен становится обязательным — подробно в уроке про CSRF. Проверяйте связку в браузере, а не на словах: вкладка Network показывает и Set-Cookie с флагами, и то, какие куки реально уехали с запросом — две минуты в devtools заменяют час чтения спеки.
Атрибуты cookie для jwt-токена#
| Атрибут | Что делает | Дефолт для access-сессии |
|---|---|---|
| HttpOnly | скрывает значение от document.cookie и скриптов | включён всегда |
| Secure | отправка только по HTTPS | включён всегда |
| SameSite | политика кросс-сайтовых запросов | Lax |
| Path | маршрут, на который кука уезжает | / для access, узкий для refresh |
| MaxAge | срок жизни куки в секундах | равен сроку токена |
Что меняется в middleware при cookie сессии go#
Серверная правка компактная. Вместо разбора заголовка — чтение куки: c, err := r.Cookie("access"), при ошибке — 401 без стыда, ровно как с пустым Authorization. Дальше строка идёт в привычный ParseWithClaims с WithValidMethods, и всё построенное поверх — контекст с пользователем, RequireRole из одиннадцатого урока — работает без изменений. Полезный компромисс на переходный период — дуал: сначала кука, потом заголовок Bearer; один эндпоинт обслуживает браузер, мобильное приложение и curl в скриптах. Дуал я считаю временным решением: два транспорта — вдвое больше сочетаний для тестов и поверхности для ошибок, и как только аудитория клиента определится, второй вариант стоит убрать. Полная серверная сборка на chi, где такое middleware встраивается в цепочку, разобрана в курсе по chi; на gin механика та же — c.Cookie со схожими флагами, детали в уроке про JWT в gin. Проверка после правки проста: запрос без куки обязан вернуть 401, запрос с кукой — пройти до хендлера; оба случая по отдельности гоняются curl-ом с файлом куки.
У схемы есть тихие преимущества, которые всплывают в эксплуатации. Сессия переживает перезагрузку без вызова refresh: access в куке живёт свои пятнадцать минут и обновляется на лету. Усиление безопасности не требует миграции фронтенда — флаги ставит сервер. Бюджет XSS ломается: скрипт, попавший на страницу, не может ни прочитать токен, ни вынести его за пределы браузера жертвы — он может только слать запросы изнутри, и то ограниченно при правильном SameSite. Цена — CSRF и чуть более хитрая отладка: из консоли куку не прочитать, и новичкам первое время это кажется поломкой. Для SPA с разделёнными доменами гибрид из четырнадцатого урока часто удобнее; для монолита с шаблонами или сайта с классическими страницами httponly cookie jwt — дефолт, от которого отступают по конкретной причине, а не по вкусу. Мобильные клиенты в эту схему не вписываются — они пользуются заголовком Authorization, и это не дефект, а повод держать дуал, пока у вас смешанная аудитория.
Типичная история: разработчик хочет прочитать значение куки в консоли, снимает HttpOnly в конфиге «на час» и коммитит вместе с фичей. Через месяц XSS в стороннем виджете забирает токен одним вызовом document.cookie — и никакой сигнализации об этом нет. Вторая вариация та же: Secure снимают, чтобы тестировать по http, и забывают вернуть. Лечится дисциплиной: флаги живут в одном месте рядом с выпуском токена, а отладочное чтение — через вкладку Application в devtools, которая показывает и HttpOnly куки без снятия флага.
Если пара access/refresh уже есть, не кладите оба токена в куки с Path=/: refresh станет прилетать на каждый эндпоинт и увеличит урон от любой утечки серверной стороны или логов. Узкий Path=/api/auth/refresh оставляет куке один маршрут. Полный сценарий с ротацией соберём в уроке про жизненный цикл сессии.
Выход: MaxAge -1 и denylist
GOfunc handleLogout(w http.ResponseWriter, r *http.Request) {
if c, err := r.Cookie("access"); err == nil {
_ = Revoke(c.Value)
}
http.SetCookie(w, &http.Cookie{
Name: "access",
Value: "",
Path: "/",
HttpOnly: true,
Secure: true,
SameSite: http.SameSiteLaxMode,
MaxAge: -1,
})
w.WriteHeader(http.StatusNoContent)
}Выход — это два действия, а не одно. MaxAge меньше нуля приказывает браузеру немедленно удалить куку, но сам jwt остаётся криптографически годным до exp: его гасит запись в denylist по jti, собранная в уроке про отзыв. Обнулять Value не обязательно, но в логах пустая кука читается проще. Статус 204 сообщает фронту, что повторять запрос бессмысленно.
Проверка: HttpOnly и скрипт
Что прочитает скрипт в document.cookie, если access-кука выставлена с HttpOnly?
Проверка: SameSite и кросс-сайтовый POST
Пользователь залогинен, access-кука с SameSite Lax. Чужой сайт отправляет POST-форму на ваш /api/transfer. Что увидит сервер?
Strict против ссылки из письма
Прочитайте код и выберите, что он выведет:
// кука выставлена так:
// Set-Cookie: access=eyJ...; HttpOnly; Secure; SameSite=Strict
// пользователь читает письмо в чужом веб-клиенте
// и кликает ссылку на https://api.example.com/api/inbox
// что получит сервер?Восстановите выставление cookie
Допишите флаги защищённой access-куки: имя, два булевых флага и политику SameSite.
Соберите выпуск cookie-сессии
Расставьте строки хендлера в правильном порядке: от выпуска токена до закрывающей скобки SetCookie.
Строки
Ваша программа
Нажимайте на строки слева, чтобы собрать программу
Карточки: cookie-сессия
Cookie-сессия закрывает клиентскую половину истории; серверную вы уже умеете: middleware из десятого урока проверяет строку так же, как и раньше, а роли ложатся сверху без правок. Когда сессия нужна не человеку, а соседнему сервису, транспорт меняется — об этом следующий урок про сервисные токены. Второй слой защиты куки от чужих форм — CSRF-токен — разобран в уроке двадцать первом. Пароль, который проверяется перед выпуском куки, хешируется по правилам урока про bcrypt. Повторить сценарий проще всего на локальном Go: два хендлера и curl с сохранением куки в файл дают полный цикл за пару минут. Карта всех тем — на странице курса jwt-go; cookie-механика в другом фреймворке собрана в уроке про cookie и сессии gin.
Песочница урока
Редактируйте код и запускайте его прямо здесь — интерпретатор работает в браузере, ничего устанавливать не нужно. За каждый запуск — +2 XP.
Запустите код, чтобы увидеть вывод.
Коротко: что запомнить
- HttpOnly cookie jwt — сессия без JS: сервер ставит куку, браузер возит её сам, скрипт значение не читает.
- Связка флагов: HttpOnly против XSS-кражи, Secure против открытого HTTP, SameSite Lax против кросс-сайтового POST.
- Серверная правка минимальна: r.Cookie вместо заголовка, дальше тот же ParseWithClaims и RequireRole.
- Refresh-куке — узкий Path вроде /api/auth/refresh, чтобы кука не прилетала на весь API.
- Logout — два действия: кука с MaxAge -1 и запись в denylist по jti.
- Цена автоматики — CSRF: SameSite режет вектор первым слоем, CSRF-токен вторым.
Домашнее задание
Понадобится локальный Go: соберите хендлеры login и logout из урока, выпустите access-куку с тремя флагами и проверьте в devtools — вкладка Application должна показать HttpOnly, а document.cookie остаться пустым. Затем отправьте POST-форму на защищённый эндпоинт со страницы на другом порту и посмотрите в логах, что SameSite Lax оставил куку дома. Добавьте к logout запись в denylist и убедитесь, что сохранённая до выхода копия токена больше не проходит — это и есть критерий, что выход настоящий, а не косметический. Заодно запишите, какой из трёх флагов вы сняли бы последним, если бы пришлось; правильный ответ — никакой.
Личные заметкиПусто
Вопросы и ответы по уроку
JWT в httpOnly cookie — не теряем ли statelessness?
Нет: состояние лежит в подписанном токене, сервер по-прежнему не хранит ничего между запросами. Кука — транспорт, а не серверное хранилище: подпись и exp по-прежнему несут всю проверку. Сравнение классических сессий и JWT по числу ходов и точкам отказа есть в уроке про хранение токена.
Как отлаживать HttpOnly куку?
Вкладка Application в devtools показывает все куки, включая HttpOnly, со значениями и флагами — снимать флаг ради отладки не нужно. На сервере удобно логировать факт присутствия куки и срок её жизни, но не само значение токена.
Что делать с мобильными приложениями?
Мобильные клиенты обычно ходят с заголовком Authorization: Bearer из защищённого хранилища платформы — Keychain у iOS, EncryptedSharedPreferences у Android. Поэтому дуал кука плюс заголовок оправдан на переходный период или как постоянная схема для смешанной аудитории.
Термины урока
Ключевые понятия из этого урока — короткие определения в глоссарии. Наведите на термин, чтобы вспомнить, откройте карточку для подробностей и ссылки на практику.
Изучайте дальше
Следующая ступень учебного пути
Telegram-боты на Go: от эха до полноценного сервиса
Учимся писать Telegram-ботов на Go: токен BotFather, команды, клавиатуры, состояния диалога, медиа, рассылки, webhook и деплой. 25 уроков с кодом.
- Создавать бота через BotFather и хранить токен безопасно
- Получать обновления через long polling и webhook
- Отвечать сообщениями, командами и клавиатурами
- Строить многошаговые диалоги с сохранением состояния