---
title: "HttpOnly cookie: сессия без JS"
description: "HttpOnly cookie jwt-сессия в Go: SameSite и Secure, почему скрипт не читает куку, выдача и сброс сессии из хендлера, связка с refresh и CSRF. С кодом."
course: "JWT-аутентификация в Go: golang-jwt и bcrypt"
url: "https://golangmaster.ru/courses/jwt-go/lessons/16-httponly-cookie/"
published: "2026-09-19"
updated: "2026-10-02"
minutes: 18
---

# HttpOnly cookie: сессия без JS

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

HttpOnly cookie держит jwt-сессию так, что JavaScript её не видит: сервер ставит куку, браузер сам прикладывает её к каждому запросу, а document.cookie остаётся пустым. Разбираем флаги SameSite и Secure, выдачу cookie сессии из Go-хендлера, погашение при выходе и цену, которую схема платит за автоматику — CSRF.

После урока про хранение токена логичный вопрос: если 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 из [шестого урока](/courses/jwt-go/lessons/06-parsing-token/). Меняется транспорт — кука вместо заголовка Authorization из [урока про middleware](/courses/jwt-go/lessons/10-middleware-auth/) — а проверка подписи, exp и ролей остаётся ровно той же. JWT внутри куки — всё тот же токен, просто в другой обёртке. Отсюда практический вывод: перевод сервиса на куки не трогает выпуск токенов и не ослабляет проверку — меняется только то, откуда middleware читает строку, и это ограничивает правки одним файлом.

### Выдача cookie сессии go-хендлером

```go
func 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](/courses/jwt-go/lessons/21-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 из [одиннадцатого урока](/courses/jwt-go/lessons/11-roli-i-prava/) — работает без изменений. Полезный компромисс на переходный период — дуал: сначала кука, потом заголовок Bearer; один эндпоинт обслуживает браузер, мобильное приложение и curl в скриптах. Дуал я считаю временным решением: два транспорта — вдвое больше сочетаний для тестов и поверхности для ошибок, и как только аудитория клиента определится, второй вариант стоит убрать. Полная серверная сборка на chi, где такое middleware встраивается в цепочку, разобрана в [курсе по chi](/courses/); на gin механика та же — c.Cookie со схожими флагами, детали в [уроке про JWT в gin](/courses/gin/lessons/15-jwt/). Проверка после правки проста: запрос без куки обязан вернуть 401, запрос с кукой — пройти до хендлера; оба случая по отдельности гоняются curl-ом с файлом куки.

У схемы есть тихие преимущества, которые всплывают в эксплуатации. Сессия переживает перезагрузку без вызова refresh: access в куке живёт свои пятнадцать минут и обновляется на лету. Усиление безопасности не требует миграции фронтенда — флаги ставит сервер. Бюджет XSS ломается: скрипт, попавший на страницу, не может ни прочитать токен, ни вынести его за пределы браузера жертвы — он может только слать запросы изнутри, и то ограниченно при правильном SameSite. Цена — CSRF и чуть более хитрая отладка: из консоли куку не прочитать, и новичкам первое время это кажется поломкой. Для SPA с разделёнными доменами гибрид из [четырнадцатого урока](/courses/jwt-go/lessons/14-hranenie-tokena/) часто удобнее; для монолита с шаблонами или сайта с классическими страницами httponly cookie jwt — дефолт, от которого отступают по конкретной причине, а не по вкусу. Мобильные клиенты в эту схему не вписываются — они пользуются заголовком Authorization, и это не дефект, а повод держать дуал, пока у вас смешанная аудитория.

> **Живое место: кука без HttpOnly «на время отладки».** Типичная история: разработчик хочет прочитать значение куки в консоли, снимает HttpOnly в конфиге «на час» и коммитит вместе с фичей. Через месяц XSS в стороннем виджете забирает токен одним вызовом document.cookie — и никакой сигнализации об этом нет. Вторая вариация та же: Secure снимают, чтобы тестировать по http, и забывают вернуть. Лечится дисциплиной: флаги живут в одном месте рядом с выпуском токена, а отладочное чтение — через вкладку Application в devtools, которая показывает и HttpOnly куки без снятия флага.

> **Refresh-куке нужен свой Path.** Если пара access/refresh уже есть, не кладите оба токена в куки с Path=/: refresh станет прилетать на каждый эндпоинт и увеличит урон от любой утечки серверной стороны или логов. Узкий Path=/api/auth/refresh оставляет куке один маршрут. Полный сценарий с ротацией соберём в [уроке про жизненный цикл сессии](/courses/jwt-go/lessons/19-zhiznennyj-cikl/).

### Выход: MaxAge -1 и denylist

```go
func 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, собранная в [уроке про отзыв](/courses/jwt-go/lessons/15-otzyv-tokenov/). Обнулять Value не обязательно, но в логах пустая кука читается проще. Статус 204 сообщает фронту, что повторять запрос бессмысленно.

### Проверка: HttpOnly и скрипт

**Вопрос:** Что прочитает скрипт в document.cookie, если access-кука выставлена с HttpOnly?

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

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

### Проверка: SameSite и кросс-сайтовый POST

**Вопрос:** Пользователь залогинен, access-кука с SameSite Lax. Чужой сайт отправляет POST-форму на ваш /api/transfer. Что увидит сервер?

- Запрос с кукой — CSRF пройдёт
- ✅ Запрос без куки: Lax не отправляет её в кросс-сайтовом POST
- Ошибка браузера, запрос вообще не уйдёт
- Куку с пометкой SameSite для разбора

Lax пропускает куку только в безопасных переходах верхнего уровня вроде клика по ссылке; POST-форма с чужого сайта поедет без куки, и middleware вернёт 401. Strict режет даже переходы по ссылке, None пускает всё и требует CSRF-токен. Защита работает, только пока сервер действительно проверяет токен на каждом запросе: кука без middleware — просто строка в заголовке, которую никто не сверяет. Поэтому SameSite настраивается вместе с проверкой, а не вместо неё.

### Strict против ссылки из письма

```go
// кука выставлена так:
// Set-Cookie: access=eyJ...; HttpOnly; Secure; SameSite=Strict

// пользователь читает письмо в чужом веб-клиенте
// и кликает ссылку на https://api.example.com/api/inbox
// что получит сервер?
```

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

- Обычный запрос с кукой: переход по ссылке — безопасный верхний уровень
- ✅ Запрос без куки: Strict не отправляет её даже в переходах с чужого сайта
- 403 от браузера ещё до отправки
- Кука отправится, но с пометкой для отчёта

Strict — самая жёсткая политика: кука не уедет с чужого сайта ни в POST, ни в переход по ссылке, и человек увидит страницу без сессии. За это платят удобством: первое посещение по внешней ссылке выглядит как выход, хотя формально сессия жива. Поэтому Lax — обычный выбор, а Strict — для операций, где цена компрометации высока: финансовые кабинеты, админки, всё, где вход со сторонней страницы выглядит подозрительнее неудобства.

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

Допишите флаги защищённой access-куки: имя, два булевых флага и политику SameSite.

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

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

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

### Соберите выпуск cookie-сессии

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

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

1. `token, err := IssueAccess(userID, role)`
2. `http.SetCookie(w, &http.Cookie{`
3. `Name:     "access",`
4. `HttpOnly: true,`
5. `SameSite: http.SameSiteLaxMode,`
6. `})`

### Карточки: cookie-сессия

- **HttpOnly** — Атрибут cookie: значение скрыто от document.cookie и любого скрипта. Браузер хранит и отправляет куку сам; XSS не может прочитать токен.
- **SameSite Lax** — Пускает куку в безопасных переходах верхнего уровня, не отправляет в кросс-сайтовых POST. Первый слой защиты от CSRF без потери удобства.
- **SameSite Strict** — Кука не уезжает с чужого сайта вовсе — даже по клику по ссылке. Максимальная защита, платит неудобством после внешних ссылок.
- **Secure** — Запрет передачи по открытому HTTP: браузер отправит куку только по HTTPS. Без него токен может утечь между роутером и сервером.
- **MaxAge: -1** — Приказ браузеру немедленно удалить куку — серверная половина logout. Сам токен при этом гасится записью в denylist по jti.

Cookie-сессия закрывает клиентскую половину истории; серверную вы уже умеете: middleware из десятого урока проверяет строку так же, как и раньше, а роли ложатся сверху без правок. Когда сессия нужна не человеку, а соседнему сервису, транспорт меняется — об этом [следующий урок про сервисные токены](/courses/jwt-go/lessons/17-service-tokeny/). Второй слой защиты куки от чужих форм — CSRF-токен — разобран в [уроке двадцать первом](/courses/jwt-go/lessons/21-csrf/). Пароль, который проверяется перед выпуском куки, хешируется по правилам [урока про bcrypt](/courses/jwt-go/lessons/09-bcrypt-paroli/). Повторить сценарий проще всего на локальном Go: два хендлера и curl с сохранением куки в файл дают полный цикл за пару минут. Карта всех тем — [на странице курса jwt-go](/courses/jwt-go/); cookie-механика в другом фреймворке собрана в [уроке про cookie и сессии gin](/courses/gin/lessons/14-cookie-sessii/).

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

- 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. Поэтому дуал кука плюс заголовок оправдан на переходный период или как постоянная схема для смешанной аудитории.
