---
title: "Cookie и сессии: c.SetCookie, echo-contrib/session и CookieStore"
description: "echo cookie: c.SetCookie и c.Cookie поверх net/http, сессии echo-contrib/session на CookieStore из gorilla/sessions, флеш-сообщения и подпись securecookie."
course: "Echo: минималистичный быстрый фреймворк"
url: "https://golangmaster.ru/courses/echo/lessons/14-cookie-sessii/"
published: "2026-09-19"
updated: "2026-09-21"
minutes: 18
---

# Cookie и сессии: c.SetCookie, echo-contrib/session и CookieStore

*Курс «Echo: минималистичный быстрый фреймворк», урок 14 из 25. ~18 минут. Обновлено 21.09.2026. Источник: https://golangmaster.ru/courses/echo/lessons/14-cookie-sessii/*

echo cookie — тема, которой обещал тринадцатый урок: форма добавила задачу, но так и не узнала, кто её отправил, — форма без сессии не отличит пользователя от пользователя. Сегодня закрываем разрыв: строим пользовательскую сессию на :1323 рядом с каноном, не трогая ни bearerAuth, ни маршруты /api/v1. Сверяемся с raw-исходниками v4.15.0: в context.go про куки ровно три метода — Cookie, SetCookie и Cookies — и все три оказываются однострочниками над net/http, самыми короткими во всём файле. Дальше поднимаем уровень: [echo-contrib/session](/courses/echo/lessons/13-formy-fajly/) приедет не один — под ним честно обнаружится gorilla/sessions, и совместимость с Gorilla окажется не маркетингом, а типом sessions.Store в сигнатуре middleware. Разберём session.Middleware и session.Get, карту Values, флеш-сообщения, ключевые пары CookieStore, дефолтные Options, которые на http-локалке игнорируются браузером, и подпись значений через securecookie. Безопасность кук — Secure, HttpOnly, SameSite — пройдём настолько, насколько нужно echo: браузерная механика уже разобрана в курсе fiber. Приступаем — HTTP без памяти первым ходом превращается в сервер, который узнаёт клиента по имени.

### echo cookie: форма узнала пользователя

Запрос «echo cookie» начинается там, где HTTP заканчивается. Протокол stateless: сервер обрабатывает каждый запрос как незнакомца — ни логина, ни истории, ни корзины. Кука — это договор о памяти: сервер в ответе выставляет заголовок Set-Cookie, браузер сохраняет его в своём хранилище и на каждом следующем запросе к этому сайту прикладывает заголовок Cookie с теми же парами имя=значение. Всё остальное — сессии, флеш-сообщения, remember-me — надстройки над этими двумя заголовками.

В echo работа с куками живёт в context.go, и контраст с формами из тринадцатого урока разительный: там был четвёртый метод и многоэтажный разбор multipart, здесь — три однострочника, после которых echo заканчивается и начинается net/http. Но тонкий слой обманчив: под ним сидят невалидные имена, которые молча не доходят до клиента, приоритеты истечения Expires против MaxAge и константы SameSite, из-за которых кука на localhost просто не вернётся. Мы пройдём весь стек честно: сначала сырые *http.Cookie и три метода контекста, затем echo-contrib/session — официальный мост к gorilla/sessions, чей интерфейс sessions.Store echo-contrib принимает как есть, не реализуя ничего своего. Сессия пользователя ляжет в Values, флеш-сообщения — под ключ _flash, подпись значений обеспечит securecookie с его HMAC. К концу урока POST /login на :1323 будет возвращать Set-Cookie с сессией, а GET /me — узнавать alice по имени.

### Set-Cookie и Cookie: два заголовка одной медали

Направление данных разное, и для него в net/http есть две разные структуры доступа. Исходящий путь: вы собираете &http.Cookie и зовёте http.SetCookie — она сериализует куку методом Cookie.String() и добавляет строку в заголовок ответа Set-Cookie. Входящий путь: браузер присылает заголовок Cookie в виде name1=value1; name2=value2 — и Request.Cookie(name) выцепляет одну куку по имени, а Request.Cookies() парсит их все. Док-комментарий у Cookies лаконичен: «Cookies parses and returns the HTTP cookies sent with the request» — парсинг живёт в readCookies, и echo сюда не добавляет ни строчки.

Одно направление — одна структура, и это важно понимать до кода. В ответе куку описывают атрибуты Path, Domain, Expires, MaxAge, Secure, HttpOnly, SameSite — они говорят браузеру, КОГДА и КУДА её отправлять; в запросе ни одного из этих атрибутов нет, клиент присылает только пары. Вручную запросы собирает Request.AddCookie — с оговоркой RFC 6265: «AddCookie does not attach more than one Cookie header field. That means all cookies, if any, are written into the same line, separated by semicolon» — все куки уезжают одной строкой через точку с запятой, второго заголовка Cookie не будет.

Echo не прячет ни одну из сторон: c.SetCookie добавляет Set-Cookie в ответ, c.Cookie и c.Cookies читают входящие. Каждый из трёх методов — чистое делегирование, и это не лень, а философия фреймворка: echo держится тонкой обёрткой, а вся политика — валидность имени, приоритет Max-Age над Expires, скоуп Path — остаётся в стандартной библиотеке, где её можно прочитать и проверить. Теперь к точным строкам context.go.

### Поле за полем: *http.Cookie

| Поле | Тип | Смысл и нюанс |
| --- | --- | --- |
| Name | string | Токен: невалидное имя — кука молча не уйдёт в Set-Cookie |
| Value | string | Значение; при сериализации проходит sanitizeCookieValue |
| Path | string | Скоуп пути; "/" — кука на весь сайт |
| Domain | string | Хост; невалидный домен логируется и отбрасывается — кука станет host-only |
| Expires | time.Time | Дата смерти в формате RFC; валидность проверяет validCookieExpires |
| MaxAge | int | 0 — атрибута нет (кука до закрытия браузера), <0 — удалить сейчас, >0 — секунды |
| Secure | bool | Отправлять только по HTTPS |
| HttpOnly | bool | Не отдавать скрипту: document.cookie его не увидит |
| SameSite | SameSite | Константы http.SameSiteDefaultMode, Lax, Strict, None — iota + 1 |

### context.go: трио кук — сырые строки v4.15.0

```go
// context.go, v4.15.0 — интерфейс echo.Context
// Cookie returns the named cookie provided in the request.
Cookie(name string) (*http.Cookie, error)
// SetCookie adds a `Set-Cookie` header in HTTP response.
SetCookie(cookie *http.Cookie)
// Cookies returns the HTTP cookies sent with the request.
Cookies() []*http.Cookie

// context.go, v4.15.0 — реализации: три однострочника net/http
func (c *context) Cookie(name string) (*http.Cookie, error) {
        return c.request.Cookie(name)
}

func (c *context) SetCookie(cookie *http.Cookie) {
        http.SetCookie(c.Response(), cookie)
}

func (c *context) Cookies() []*http.Cookie {
        return c.request.Cookies()
}

// net/http, cookie.go — что стоит за SetCookie
// SetCookie adds a Set-Cookie header to the provided [ResponseWriter]'s headers.
// The provided cookie must have a valid Name. Invalid cookies may be
// silently dropped.
func SetCookie(w ResponseWriter, cookie *Cookie) {
        if v := cookie.String(); v != "" {
                w.Header().Add("Set-Cookie", v)
        }
}
```

Контракт целиком: интерфейс — три строки, реализации — по одной. c.Cookie делегирует Request.Cookie и возвращает ошибку: промах — это http.ErrNoCookie с текстом «http: named cookie not present» (в старых выпусках Go переменная называлась ErrNoCookies, текст тот же); если кук с таким именем несколько, вернётся только одна — «If multiple cookies match the given name, only one cookie will be returned». c.Cookies читает весь входящий заголовок. c.SetCookie зовёт http.SetCookie(c.Response(), cookie): c.Response() — это *echo.Response, реализующий http.ResponseWriter, так что заголовок Set-Cookie добавляется в обычную карту заголовков. Самая важная строка — условие if v := cookie.String(); v != "": для невалидного имени Cookie.String() возвращает пустую строку («If c is nil or c.Name is invalid, the empty string is returned») — и заголовок просто не добавится, без ошибки и без паники. Ещё деталь сериализации: MaxAge<0 пишется в провод как «Max-Age=0» — «MaxAge<0 means delete cookie now, equivalently 'Max-Age: 0'».

### Проверьте себя: невалидное имя куки

**Вопрос:** Обработчик вызывает c.SetCookie(&http.Cookie{Name: "bad name", Value: "x"}) — в имени пробел. Клиент получит ответ без ошибок. Что окажется в заголовках ответа?

- ✅ Заголовка Set-Cookie не будет вовсе: http.SetCookie молча пропускает невалидную куку
- Кука уйдёт с экранированным именем: net/http заменит пробел и отправит
- echo вернёт 500: SetCookie проверяет имя и возвращает ошибку
- Паника: SetCookie требует валидное имя и паникует иначе

Док-комментарий говорит прямо: «The provided cookie must have a valid Name. Invalid cookies may be silently dropped». Механика видна в теле: if v := cookie.String(); v != "" — а String() возвращает пустую строку, если !isToken(c.Name). Ни ошибки, ни лога, ни паники — Set-Cookie просто не появится в ответе, и отладить это без чтения исходников трудно. Практический вывод: имя куки — токен из букв, цифр и знаков вроде !#$%&'*+-.^_`|~; пробелы и запятые туда не входят. Если валидность важна на этапе отладки, у *http.Cookie есть метод Valid, возвращающий ошибку вида http: invalid Cookie.Name.

### Канон: кука темы на /api/v1/theme

```go
// main.go — прямая работа с *http.Cookie в каноне :1323
func setTheme(c echo.Context) error {
        theme := c.QueryParam("theme") // POST /api/v1/theme?theme=dark
        if theme != "dark" && theme != "light" {
                return echo.NewHTTPError(http.StatusBadRequest, "theme: только dark или light")
        }
        c.SetCookie(&http.Cookie{
                Name:     "theme",
                Value:    theme,
                Path:     "/",           // кука на весь сайт
                MaxAge:   86400 * 7,     // неделя в секундах
                Secure:   false,         // локально без HTTPS; в проде — true
                HttpOnly: true,          // скрипту страницы не видна
                SameSite: http.SameSiteLaxMode,
        })
        return c.JSON(http.StatusOK, map[string]string{"theme": theme})
}

func getTheme(c echo.Context) error {
        ck, err := c.Cookie("theme") // делегирует Request.Cookie
        if errors.Is(err, http.ErrNoCookie) {
                return c.JSON(http.StatusOK, map[string]string{"theme": "default"})
        }
        if err != nil {
                return err
        }
        return c.JSON(http.StatusOK, map[string]string{"theme": ck.Value})
}

// регистрация в v1-группе рядом с задачами и вложениями:
//   v1.GET("/theme", getTheme)
//   v1.POST("/theme", setTheme)
//
// curl -si -X POST -d "theme=dark" -H "Authorization: Bearer dev-token-42" \
//   "localhost:1323/api/v1/theme?theme=dark" | rg -i set-cookie
//   Set-Cookie: theme=dark; Path=/; Max-Age=604800; HttpOnly; SameSite=Lax
// curl -s -b "theme=dark" -H "Authorization: Bearer dev-token-42" \
//   localhost:1323/api/v1/theme → {"theme":"dark"}
```

Канон продолжается: bearerAuth с токеном dev-token-42 охраняет /api/v1, кука темы живёт рядом с задачами и вложениями. setTheme собирает &http.Cookie литералом — все атрибуты на месте, поэтому провод предсказуем: «theme=dark; Path=/; Max-Age=604800; HttpOnly; SameSite=Lax» — ровно в том порядке, в котором Cookie.String() пишет атрибуты: Path, Domain, Expires, Max-Age, HttpOnly, Secure, SameSite. getTheme различает два случая через errors.Is(err, http.ErrNoCookie): промах — честный «default» в JSON, прочие ошибки — наверх в ErrorHandler, где они превратятся в JSON-конверт, потому что путь /api. Флаг -b в curl подкладывает заголовок Cookie руками — браузерный jar при этом не участвует, поэтому Secure-куки curl по http всё равно отправит; браузер — нет, и это различие ещё выстрелит в разделе про дефолты CookieStore.

### Expires против MaxAge и три флага: Secure, HttpOnly, SameSite

Времени у куки два механизма, и в Go они конкурируют. Expires — абсолютная дата; MaxAge — относительный счётчик в секундах, и его семантика закомментирована в структуре дословно: «MaxAge=0 means no 'Max-Age' attribute specified», «MaxAge<0 means delete cookie now, equivalently 'Max-Age: 0'», «MaxAge>0 means Max-Age attribute present and given in seconds». Ноль — не «удалить» и не «навсегда», а «атрибута нет»: кука станет сессионной и умрёт вместе с браузером. Если заполнить оба поля, Cookie.String() выпишет оба заголовка; gorilla не полагается на приоритеты браузеров — его NewCookie сам рассчитывает Expires из MaxAge «for Internet Explorer compatibility», но об этом после.

Три флага — про границы доверия. Secure запрещает браузеру отправлять куку по http; HttpOnly прячет её от document.cookie, снимая половину прибыли с кражи через XSS; SameSite решает, ехать ли куке в кросс-сайтовых запросах. В Go это константы типа http.SameSite — `SameSiteDefaultMode SameSite = iota + 1`, дальше Lax, Strict, None, — а их смысл сформулирован в доке стандартной библиотеки: «SameSite allows a server to define a cookie attribute making it impossible for the browser to send this cookie along with cross-site requests. The main goal is to mitigate the risk of cross-origin information leakage, and provide some protection against cross-site request forgery attacks».

Здесь мы останавливаемся сознательно: браузерная механика этих трёх флагов — почему None требует Secure, что именно Lax пропускает, а Strict нет, чем опасен document.cookie — уже разобрана в [уроке fiber про cookie и сессии](/courses/fiber/lessons/12-cookie-sessii/), и пересказывать её второй раз нечестно. Для echo важно другое: атрибуты вы ставите руками в каждом &http.Cookie, а в сессиях — один раз в Options хранилища, и от дефолтов этого Options вас будет отделять один сюрприз.

### От кук к сессиям echo: зачем нужен CookieStore

Кука с голым значением — это память без авторства. Три ограничения становятся заметны сразу. Первое: значение видит клиент — заголовок Set-Cookie читается глазами, а document.cookie и панель разработчика делают его общедоступным; любые «userId=7» — приглашение к подмене. Второе: объём — практический потолок куки около четырёх килобайт на пару имя-значение, и на большее не рассчитывайте. Третье: структура — в Value живёт строка, и каждую вложенную сущность надо сериализовать и защитить отдельно.

Сессия решает всё разом: данные лежат в карте Values — но на какой стороне, зависит от хранилища. В классической cookie-сессии данные не прячутся на сервер: они сериализуются, подписываются ключом (и опционально шифруются) и целиком уезжают в куку — сервер остаётся без состояния: подделать значение без ключа нельзя, а прочитать без шифрования можно. Запрос «сессии echo» выводит ровно на эту конструкцию: пакет github.com/labstack/echo-contrib/session — официальный мост echo к gorilla/sessions, стандарту де-факто с 2012 года. Совместимость с Gorilla здесь честная до типа: echo-contrib не реализовал своё хранилище — он принимает ЛЮБОЙ sessions.Store из gorilla и больше ничего не хранит. CookieStore, FilesystemStore, ваш собственный имплементатор трёх методов — всё это подходит без адаптеров.

Сравнение с курсом fiber из его двенадцатого урока уместно одной строкой: у fiber v3 собственная session-механика с адаптерами Storage, у echo — мост к зрелому gorilla; разные философии, одинаковый протокол Set-Cookie под ними. В gorilla/sessions Session — это struct с картой Values, а Store — интерфейс из трёх методов: Get, New, Save. Разберём обе стороны моста по исходникам.

### session.go: мост echo-contrib — сырые строки v0.18.0

```go
// session.go, echo-contrib v0.18.0 — последний релиз под echo/v4
// (go.mod: github.com/labstack/echo/v4 v4.15.0, gorilla/sessions v1.4.0)
// в master-ветке echo-contrib уже echo/v5

type (
        // Config defines the config for Session middleware.
        Config struct {
                // Skipper defines a function to skip middleware.
                Skipper middleware.Skipper

                // Session store.
                // Required.
                Store sessions.Store
        }
)

const (
        key = "_session_store"
)

// Get returns a named session.
func Get(name string, c echo.Context) (*sessions.Session, error) {
        s := c.Get(key)
        if s == nil {
                return nil, fmt.Errorf("%q session store not found", key)
        }
        store := s.(sessions.Store)
        return store.Get(c.Request(), name)
}

// MiddlewareWithConfig returns a Sessions middleware with config.
func MiddlewareWithConfig(config Config) echo.MiddlewareFunc {
        // Defaults
        if config.Skipper == nil {
                config.Skipper = DefaultConfig.Skipper
        }
        if config.Store == nil {
                panic("echo: session middleware requires store")
        }

        return func(next echo.HandlerFunc) echo.HandlerFunc {
                return func(c echo.Context) error {
                        if config.Skipper(c) {
                                return next(c)
                        }
                        defer context.Clear(c.Request())
                        c.Set(key, config.Store)
                        return next(c)
                }
        }
}
```

Мост оказался крошечным — и это лучшая гарантия совместимости. Middleware (обёртка над MiddlewareWithConfig) не трогает ни куки, ни данные: он один раз кладёт хранилище в контекст запроса — c.Set(key, config.Store) под ключом "_session_store" — и всё. session.Get(name, c) достаёт store обратно через c.Get и делает тип-ассершн s.(sessions.Store): если middleware не подключён, c.Get вернёт nil и вы получите ошибку «"_session_store" session store not found», а не панику. Дальше — store.Get(c.Request(), name): запрос передаётся gorilla как есть, echo-contrib заканчивается. Две детали честности: nil Store ловится паникой на этапе монтирования — «echo: session middleware requires store» — ошибка конфигурации проваливается рано; а строка defer context.Clear(c.Request()) — пережиток архивированного пакета gorilla/context, который подчищает его глобальную карту по завершении запроса; в v5-ветке этой строки уже нет. Кук в echo-contrib ровно ноль — все Set-Cookie выставляет gorilla.

### Проверьте себя: Get без middleware

**Вопрос:** session.Get("user", c) вызван в обработчике, но e.Use(session.Middleware(store)) в приложении отсутствует. Что произойдёт?

- ✅ Ошибка "_session_store" session store not found: c.Get вернул nil, и Get честно её вернёт
- nil-сессию без ошибки: Get вернёт (nil, nil) и обработчик упадёт позже
- Новую пустую сессию: Get создаёт её сам, middleware нужен только для Save
- Панику: тип-ассершн s.(sessions.Store) без проверки на nil

По исходнику: s := c.Get(key); if s == nil { return nil, fmt.Errorf("%q session store not found", key) } — проверка на nil стоит ДО тип-ассершна, поэтому паники не будет, а текст ошибки подставит ключ "_session_store" в %q. Сессию Get не создаёт — создание относится к store.Get внутри gorilla, и до него дело не доходит. Паника в этом пакете вообще одна и та же ловится на этапе конфигурации: panic("echo: session middleware requires store") — она сработала бы при монтировании middleware с пустым Store, а не в обработчике. Практика: если видите этот текст в логах, вы забыли e.Use(session.Middleware(store)) или подключили его ПОСЛЕ маршрутов.

### gorilla/sessions: Session, Values и Store

Пакет gorilla/sessions держит три сущности. Session — рабочая единица: struct из четырёх экспортируемых полей — ID string, Values map[interface{}]interface{}, Options *Options и IsNew bool. Док-комментарий предупреждает заранее: ID — «generated by stores. It should not be used for user data» — это внутренний идентификатор, а не место для логина; всё ваше живёт в Values. Карта нетипизированная — ключ и значение любого типа — потому что сериализатор по умолчанию encoding/gob, который умеет почти всё, но требует gob.Register для нестандартных типов; пакет сам в init регистрирует []interface{}{}. IsNew — главный диагностический флаг: true значит, что куки не было или она не расшифровалась.

Store — интерфейс из трёх методов: Get должен вернуть кэшированную сессию, New — создать (и «should never return a nil session, even in the case of an error»), Save — сохранить. Разница между Get и New описана в доке дословно: «calling New() twice will decode the session data twice, while Get() registers and reuses the same decoded session after the first call». Реестром занимается Registry: он кладётся в контекст запроса и хранит map имя→сессия, поэтому повторный session.Get с тем же именем в пределах одного запроса бесплатен и возвращает тот же указатель. Тот же Registry — источник ошибки на невалидном имени: «sessions: invalid character in cookie name: %s» — gorilla проверяет имя куки раньше, чем net/http успеет что-то сказать.

Осталась механика конвейера: где Values превращаются в Set-Cookie и обратно. Это CookieStore — и тут же дефолтные Options, из-за которых локальный http-сервер молча теряет сессию.

### Канон: пользовательская сессия на :1323

```go
// main.go — мост к gorilla включён: /login, /me, /logout рядом с /api/v1
var devHashKey = []byte("dev-cookie-hash-key-0123456789ab")  // 32 байта; в проде — GenerateRandomKey
var devBlockKey = []byte("dev-cookie-blockkey-0123456789ab") // 32 байта — AES-256

func main() {
        e := echo.New()
        e.Use(middleware.RequestID(), middleware.Recover())

        store := session.NewCookieStore(devHashKey, devBlockKey)
        // дефолт CookieStore: Path "/", MaxAge 86400*30, SameSite None, Secure true,
        // HttpOnly false — для http-локалки переопределяем три поля:
        store.Options.SameSite = http.SameSiteLaxMode
        store.Options.Secure = false
        store.Options.HttpOnly = true
        e.Use(session.Middleware(store)) // Store доступен всем через session.Get

        e.POST("/login", login)
        e.GET("/me", me)
        e.POST("/logout", logout)
        // /api/v1 продолжает жить под bearerAuth(AuthConfig{Token: "dev-token-42", Username: "alice"}):
        // сессии обслуживают браузер, bearer-токен — API
}

func login(c echo.Context) error {
        var body map[string]string
        if err := c.Bind(&body); err != nil {
                return echo.NewHTTPError(http.StatusBadRequest, "битое тело")
        }
        if body["username"] != "alice" || body["password"] != "qwerty-42" {
                return echo.NewHTTPError(http.StatusUnauthorized, "неверные данные входа")
        }
        sess, err := session.Get("user", c) // Registry: кэш запроса → store.New
        if err != nil {
                return err // кука была, но не расшифровалась — Session всё равно вернётся
        }
        sess.Values["login"] = body["username"] // данные — обычная карта interface{}
        sess.Values["visits"] = 1
        sess.AddFlash("Вы вошли как alice") // одноразовое сообщение под _flash
        return sess.Save(c.Request(), c.Response()) // EncodeMulti → Set-Cookie
}

func me(c echo.Context) error {
        sess, err := session.Get("user", c)
        if err != nil {
                return err
        }
        login, ok := sess.Values["login"].(string) // тип-ассершн обязателен
        if !ok {
                return echo.NewHTTPError(http.StatusUnauthorized, "нет входа")
        }
        flashes := sess.Flashes() // прочитать И стереть в карте
        _ = sess.Save(c.Request(), c.Response()) // стирание и правки — сохранить
        return c.JSON(http.StatusOK, map[string]any{
                "login":   login,
                "is_new":  sess.IsNew,
                "flashes": flashes,
        })
}

func logout(c echo.Context) error {
        sess, err := session.Get("user", c)
        if err != nil {
                return err
        }
        delete(sess.Values, "login") // данные убрать
        sess.Options.MaxAge = -1     // «MaxAge<0 means delete cookie immediately»
        return sess.Save(c.Request(), c.Response()) // Expires = time.Unix(1, 0)
}
```

Три хендлера — весь жизненный цикл. login: session.Get("user", c) возвращает сессию из Registry (первый вызов — store.New), Values заполняются парами любой природы, AddFlash кладёт сообщение, и sess.Save(c.Request(), c.Response()) сериализует карту через securecookie и выставляет Set-Cookie. Заметьте контракт Save из доки: «You should call Save before writing to the response or returning from the handler» — после c.JSON сохранять уже поздно, заголовок улетел. me: доставание из Values — это тип-ассершн, потому что карта нетипизированная; Flashes читает и стирает, поэтому после него обязателен повторный Save, иначе стирание не доехало бы до клиента. logout: удаляем ключ, ставим sess.Options.MaxAge = -1 — «Individual sessions can be deleted by setting Options.MaxAge = -1 for that session» — и Save: gorilla превратит минус в Expires = time.Unix(1, 0), дату в прошлом. Дефолтный срок сессии — 86400 * 30 секунд; глобально его правит store.MaxAge(age), который синхронно настраивает и Options, и каждый кодек. bearerAuth и сессии не конфликтуют: API под токеном, браузерные страницы — под кукой user.

### Два Get в одном запросе: чей Values?

```go
// хендлер /me, один запрос:
sess1, _ := session.Get("user", c)
sess1.Values["visits"] = 7

sess2, _ := session.Get("user", c) // второй вызов с тем же именем

// что окажется в sess2.Values["visits"]?
```

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

- ✅ 7: Registry.Get вернёт тот же *Session — сессии кэшируются на запрос
- 0: второй Get декодирует куку заново в независимую сессию
- Ключа visits не будет: Values очищается между вызовами Get
- Ошибка: две сессии с одним именем в одном запросе запрещены

По доке CookieStore: «calling New() twice will decode the session data twice, while Get() registers and reuses the same decoded session after the first call». Registry хранит сессии в map[string]sessionInfo внутри контекста запроса: первый session.Get добавляет пару в реестр, второй — берёт её оттуда и возвращает тот же указатель, только перепривязывает store. Именно поэтому правки sess1 и sess2 — это одна и та же карта: sess2.Values["visits"] == 7. Дважды декодировать куку умеет только store.New — им Registry пользуется один раз. Если бы вы звали store.New напрямую через CookieStore, получили бы две независимые сессии — и вторая перезаписала бы первую при Save.

### CookieStore изнутри: New, Save и ключевые пары

CookieStore — хранилище, где вся сессия живёт в куке, а сервер только подписывает её. Конструктор описывает ключи так: «Keys are defined in pairs to allow key rotation, but the common case is to set a single authentication key and optionally an encryption key. The first key in a pair is used for authentication and the second for encryption. The encryption key can be set to nil or omitted in the last pair, but the authentication key is required in all pairs». Рекомендации по длинам тоже в доке: аутентификационному ключу — «32 or 64 bytes», ключу шифрования — «16, 24, or 32 bytes to select AES-128, AES-192, or AES-256». Из пар строится Codecs — срез securecookie.Codec через CodecsFromPairs: несколько пар — это ротация, старые куки декодируются старым ключом, новые кодируются первым.

Чтение: CookieStore.New достаёт куку из запроса и отдаёт её securecookie — `if c, errCookie := r.Cookie(name); errCookie == nil { err = securecookie.DecodeMulti(name, c.Value, &session.Values, s.Codecs...) }`; при успехе IsNew = false. Важный нюанс из доки Get: «It returns a new session and an error if the session exists but could not be decoded» — битая или подделанная кука НЕ превращается в панику и в пустоту: вы получаете новую сессию И ошибку, и решение «пустить ли гостя» остаётся вашим. Запись: Save кодирует карту — `securecookie.EncodeMulti(session.Name(), session.Values, s.Codecs...)` — и выставляет куку http.SetCookie(w, NewCookie(session.Name(), encoded, session.Options)).

Сроки: метод store.MaxAge(age) меняет Options.MaxAge и прогоняет то же число по всем кодекам, чтобы срок подписи совпадал со сроком куки; у отдельной сессии — свой Options.MaxAge, и -1 удаляет её немедленно: NewCookie при MaxAge<0 ставит «Expires = time.Unix(1, 0)» — «Set it to the past to expire now». Сам Options — подмножество полей куки, что заявлено в доке структуры: «Fields are a subset of http.Cookie fields» — те же Path, Domain, MaxAge, Secure, HttpOnly, SameSite плюс Partitioned из свежих стандартов. А вот дефолтный Options конструктора — ловушка, вынесенная в отдельный вопрос ниже. Серверная альтернатива — FilesystemStore: те же ключи и интерфейс, но сериализованная сессия лежит файлом на диске (каталог или os.TempDir), и в куке едет только идентификатор.

### Проверьте себя: дефолтный CookieStore на localhost

**Вопрос:** store := session.NewCookieStore(hashKey) без правки Options; сервер на http://localhost:1323; браузер получил Set-Cookie. Что случится при следующем переходе?

- ✅ Кука не вернётся: дефолт — SameSite None и Secure true, а Secure-куку по http браузер не отправит; надо переопределить Options
- Вернётся нормально: браузеры игнорируют флаг Secure на localhost
- Сервер ответит 400: SameSite=None без HTTPS — ошибка разбора запроса
- Кука вернётся, но без подписи: дефолтный Store не подписывает значения

Конструктор NewCookieStore задаёт Options{Path: "/", MaxAge: 86400 * 30, SameSite: http.SameSiteNoneMode, Secure: true} — и это не баг, а продовая настройка: SameSite=None валиден только вместе с Secure и HTTPS. По http://localhost браузер такую куку сохранит-таки в jar, но отправлять её по незащищённому соединению не станет — сессия будет новой на каждый запрос, IsNew = true вечно. Лечение для локальной разработки — три строки из канона: store.Options.SameSite = http.SameSiteLaxMode, store.Options.Secure = false, и заодно HttpOnly = true, потому что HttpOnly в дефолте не выставлен. Подпись тут ни при чём: она живёт в securecookie и от Secure не зависит. curl в этом смысле честнее браузера — флаг -b шлёт заголовок Cookie руками, минуя jar, поэтому curl-тесты проходят и с «неправильными» Options.

### Флеш-сообщения: одноразовые записки через сессию

Флеш — это значение, которое должно показаться ровно один раз: «задача сохранена», «неверный пароль», «файл слишком большой». Механика gorilla помещается в два метода Session и одну константу: `const flashesKey = "_flash"` — дефолтный ключ, под которым флешы лежат в той же карте Values. AddFlash(value, vars...) дописывает значение в срез под ключом (свой ключ передаётся вторым аргументом: AddFlash("err", "notice")), а Flashes(vars...) возвращает срез И стирает его — комментарий в исходнике не оставляет вариантов: «Drop the flashes and return it», в коде — delete(s.Values, key).

Отсюда три правила, из которых растёт весь паттерн. Первое: прочёл — стёр; второй вызов Flashes в том же запросе вернёт пустой срез, потому что ключ уже удалён из Values. Второе: стирание живёт только в памяти запроса, пока не случился Save — именно поэтому в каноне после sess.Flashes() стоит sess.Save: иначе «прочитанный» флеш вернётся при следующем заходе и покажется второй раз. Третье: положил — в одном запросе, прочитал — в следующем: форма с ошибкой валидации из тринадцатого урока кладёт AddFlash("title обязателен") и редиректит обратно на страницу формы, а её обработчик при рендере вызывает Flashes и показывает сообщение один раз. Пакетная версия сохранения есть и на уровне пакета: sessions.Save(r, w) — в доке дословно «Save saves all sessions used during the current request» — прогоняет Registry.Save и собирает ошибки в MultiError — удобно, когда хендлер работал с несколькими именами сессий.

В fiber двенадцатого урока тот же паттерн собирался на родной сессии — считайте это вторым свидетелем: положил → редирект → прочитал → стёр — универсальный ритм одноразовых сообщений, различаются только API. Дальше — последняя деталь конвейера: чем именно подписана ваша Values, когда она превращается в строку куки.

### Два Flashes подряд: сколько вернёт второй

```go
// в одном запросе, до Save:
sess.AddFlash("A")
sess.AddFlash("B")

first := sess.Flashes()
second := sess.Flashes()

// len(first) и len(second)?
```

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

- ✅ 2 и 0: Flashes стирает прочитанное — «Drop the flashes and return it»
- 2 и 2: Flashes читает Values, не меняя их
- 1 и 1: каждый вызов возвращает по одному сообщению
- 0 и 2: первый вызов только создаёт ключ _flash

AddFlash дважды дополняет срез под ключом _flash: Values["_flash"] == ["A", "B"]. Flashes делает три вещи: берёт срез, удаляет ключ — delete(s.Values, key) — и возвращает снятое. Поэтому first — срез из двух значений, second — nil: ключа в Values больше нет, ветка if v, ok := s.Values[key]; ok не срабатывает. До Save стирание существует только в памяти: если обработчик упадёт до sess.Save, клиент получит куку без изменений, и оба флеша приедут снова в следующем запросе — классическая причина «задвоившихся» сообщений. Хотите ключи по категориям — передавайте имя: sess.Flashes("notice") читает только Values["notice"], не трогая _flash.

### Подпись значений: securecookie и SignedString

Загадка кодирования решается в соседнем пакете gorilla/securecookie — том самом, чей Codec торчит из CookieStore.Codecs. Интерфейс двухметодный: Encode(name string, value interface{}) (string, error) и Decode(name, value string, dst interface{}) error. Конструктор — securecookie.New(hashKey, blockKey): первый ключ обязателен и «used to authenticate values using HMAC», второй опционален и включает шифрование AES (длина 16, 24 или 32 байта — AES-128/192/256). Дефолты: хеш-функция sha256.New, срок подписи 86400 * 30 секунд, потолок длины 4096 — «which is the maximum value accepted by Internet Explorer», сериализатор encoding/gob (JSONEncoder включается SetSerializer).

Конвейер Encode описан в доке целиком: «It serializes, optionally encrypts, signs with a message authentication code, and finally encodes the value». В коде подпись строится по строке «name|date|value» — имя куки участвует в MAC, поэтому кука одного имени не переиспользуется под другим. Результат — base64 от «date|value|mac». Decode идёт наоборот и на каждом шаге может отказать: ошибка «the value is not valid» (ErrMacInvalid) — подпись не совпала, причём сравнение делает subtle.ConstantTimeCompare, защита от тайминг-атак; «expired timestamp» — MAC верен, но дата за пределами maxAge; «timestamp is too new» — при настроенном MinAge. Имя при декодировании должно совпасть с именем при кодировании — проверяется тем же MAC.

И обязательная оговорка о ключах: «keys created using GenerateRandomKey() are not automatically persisted. New keys will be created when the application is restarted, and previously issued cookies will not be able to be decoded» — ключ, сгенерированный на старте, убивает все сессии при каждом деплое. Секрет хранят в окружении или файле, как любой другой конфиденциальный параметр. И не путайте это с Token.SignedString из мира JWT: там подписывается целый токен с клеймами и отдельной логикой проверки — пятнадцатый урок курса echo, JWT-аутентификация, встретит вас именно этим методом; securecookie подписывает значения кук, и у них у этого механизма иная математика и другой срок годности.

### securecookie напрямую: подписанная кука без сессии

```go
// main.go — подпись значений руками: securecookie минус сессия
var sc = securecookie.New(devHashKey, nil) // только HMAC-SHA256, без шифрования

func setSigned(c echo.Context) error {
        encoded, err := sc.Encode("pref", "dark") // serialize → MAC("pref|date|value") → base64
        if err != nil {
                return echo.NewHTTPError(http.StatusInternalServerError, "encode")
        }
        c.SetCookie(&http.Cookie{
                Name:     "pref",
                Value:    encoded,
                Path:     "/",
                HttpOnly: true,
                SameSite: http.SameSiteLaxMode,
        })
        return c.String(http.StatusOK, "ок")
}

func getSigned(c echo.Context) error {
        ck, err := c.Cookie("pref")
        if errors.Is(err, http.ErrNoCookie) {
                return echo.NewHTTPError(http.StatusBadRequest, "нет куки pref")
        }
        var value string
        if err := sc.Decode("pref", ck.Value, &value); err != nil {
                // сюда попадают и подмена значения, и чужое имя, и просроченный timestamp:
                // «the value is not valid» либо «expired timestamp»
                return echo.NewHTTPError(http.StatusBadRequest, "кука не прошла проверку")
        }
        return c.JSON(http.StatusOK, map[string]string{"pref": value})
}

// curl -si "localhost:1323/pref?theme=dark" | rg -i set-cookie
//   Set-Cookie: pref=MTcw...|value...; Path=/; HttpOnly; SameSite=Lax
// curl -s -b "pref=MTcw...подделанный хвост" localhost:1323/pref → 400
```

Тот же механизм, что у CookieStore, но без карты Values: Encode пакует одиночное значение, Decode — раскладывает в &value, и вся проверка — внутри. С devBlockKey = nil шифрования нет: значение закодировано base64 и читается клиентом как открытый текст, но подменить его нельзя — MAC на ключе devHashKey не сойдётся, и Decode вернёт ошибку со стандартным текстом «the value is not valid». Это ровно тот компромисс, о котором стоит помнить: подпись защищает целостность, шифрование — конфиденциальность; хотите и то и другое — передайте второй ключ (AES, 16/24/32 байта). CookieStore делает всё то же самое: EncodeMulti/DecodeMulti — просто циклы по нескольким кодекам ради ротации ключей. Сигнатура имени в MAC — «name|date|value» — объясняет, почему имя при Encode и Decode обязано совпадать: разное имя — другая подпись.

### Заполните пропуски: каркас сессии

Соберите каркас: легальные Options для http-локалки, монтирование моста, флеш и сохранение сессии до ответа.

```go
func main() {
        store := session.NewCookieStore(devHashKey, devBlockKey)
        store.Options.___ = http.___  // локальный http: вернуть куку в законный режим
        store.Options.Secure = ___    // иначе браузер не отправит её обратно
        store.Options.HttpOnly = true // скрипту страницы не видна
        e.Use(session.___(store))     // мост echo-contrib → gorilla Store

        e.POST("/login", func(c echo.Context) error {
                sess, err := session.Get("user", c)
                if err != nil {
                        return err
                }
                sess.Values["login"] = "alice"
                sess.___("Вы вошли") // одноразовое сообщение под ключ _flash
                return sess.___(c.Request(), c.Response()) // EncodeMulti → Set-Cookie
        })
        // ...
}
```

Подсказка: Константа SameSite живёт в net/http: http.SameSiteLaxMode. Middleware монтируется через e.Use, а сессия обязана быть сохранена ДО записи тела ответа.

Правильные ответы в пропусках: `SameSite`, `SameSiteLaxMode`, `false`, `Middleware`, `AddFlash`, `Save`

### Соберите жизненный цикл сессии

Расставьте строки в правильном порядке: хранилище до маршрутов, чтение до записи, флеш до Save, выход — последним.

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

1. `e.Use(session.Middleware(session.NewCookieStore(hashKey, blockKey))) // Store в контекст под _session_store`
2. `sess, err := session.Get("user", c) // Registry.Get: кэш запроса → store.New`
3. `// CookieStore.New: кука есть — securecookie.DecodeMulti(name, c.Value, &session.Values); IsNew = false`
4. `sess.Values["login"] = "alice" // данные — обычная карта map[interface{}]interface{}`
5. `sess.AddFlash("Вы вошли") // срез под ключом _flash`
6. `sess.Options.MaxAge = 86400 * 30 // срок можно править и на уровне сессии`
7. `sess.Save(c.Request(), c.Response()) // EncodeMulti → http.SetCookie — ДО записи тела`
8. `// следующий запрос: браузер шлёт Cookie: user=... — Get снова, IsNew уже false`
9. `sess.Options.MaxAge = -1 и Save — выход: NewCookie ставит Expires = time.Unix(1, 0)`

> **Ловушки кук и сессий.** Первая: невалидное имя куки — и Set-Cookie тихо не добавится: «Invalid cookies may be silently dropped», Cookie.String() вернёт пустую строку на пробеле в имени; ошибку не вернёт никто, диагностика — только взгляд на заголовки ответа. Вторая: забытый sess.Save — Values жили в памяти запроса и умерли вместе с ним: Set-Cookie не выставлен, второй запрос снова IsNew = true; доке безразлична причина — «You should call Save before writing to the response or returning from the handler». Третья: секрет в литерале или сгенерированный на старте — «previously issued cookies will not be able to be decoded» после каждого рестарта; выносите ключи в окружение и храните постоянно, а ротацию делайте через несколько пар в NewCookieStore. Четвёртая: дефолтный Options CookieStore — SameSite None, Secure true, HttpOnly false — продовая связка, на http-локалке дающая вечно новую сессию, а без HttpOnly — куку, видимую из document.cookie; три строки переопределения из канона решают обе проблемы.

### Карточки: куки и сессии

- **Трио кук context.go v4.15.0** — Cookie(name string) (*http.Cookie, error) → return c.request.Cookie(name); промах — ErrNoCookie «http: named cookie not present» (ранее ErrNoCookies), при нескольких одноимённых вернётся одна. SetCookie(cookie *http.Cookie) → http.SetCookie(c.Response(), cookie). Cookies() []*http.Cookie → c.request.Cookies(). echo не добавляет ничего: вся политика — в net/http.
- **http.SetCookie: тихий пропуск** — «The provided cookie must have a valid Name. Invalid cookies may be silently dropped». Тело: if v := cookie.String(); v != "" { w.Header().Add("Set-Cookie", v) } — String() возвращает "" при nil или невалидном имени (isToken). MaxAge<0 сериализуется как «Max-Age=0». Порядок атрибутов в проводе: Path, Domain, Expires, Max-Age, HttpOnly, Secure, SameSite.
- **MaxAge в *http.Cookie: три ветки** — Дословно из структуры: «MaxAge=0 means no 'Max-Age' attribute specified» (кука до закрытия браузера), «MaxAge<0 means delete cookie now, equivalently 'Max-Age: 0'», «MaxAge>0 means Max-Age attribute present and given in seconds». Ноль — НЕ «удалить»: сессионная кука. SameSite — тип http.SameSite с константами Default/Lax/Strict/None (iota + 1).
- **session.Middleware и session.Get** — echo-contrib/session v0.18.0 — последний релиз под echo/v4 (master уже на echo/v5). Middleware кладёт store в c.Set("_session_store"); panic("echo: session middleware requires store") при nil. Get(name, c): c.Get → тип-ассершн sessions.Store → store.Get(c.Request(), name); без middleware — ошибка «"_session_store" session store not found». defer context.Clear — пережиток архивированного gorilla/context.
- **CookieStore: New, Save, ключевые пары** — New: r.Cookie(name) → securecookie.DecodeMulti(name, c.Value, &session.Values, Codecs...); IsNew=false при успехе; битая кука — новая сессия И ошибка. Save: EncodeMulti(session.Name(), session.Values) → http.SetCookie(w, NewCookie(...)). Ключи парами: первая — HMAC-аутентификация (32/64 байта), вторая — AES (16/24/32), nil допустим. Дефолт Options: Path "/", MaxAge 86400*30, SameSite None, Secure true. Удаление сессии: Options.MaxAge = -1 → Expires = time.Unix(1, 0).
- **Флеши и securecookie** — Флеши: AddFlash(value, vars...) дописывает в Values["_flash"] (flashesKey), Flashes(vars...) возвращает и СТИРАЕТ — «Drop the flashes and return it» (delete из Values); стирание фиксирует sess.Save; второй Flashes в том же запросе пуст. securecookie.New(hashKey, blockKey): Encode — «serializes, optionally encrypts, signs with a message authentication code, and finally encodes», MAC по «name|date|value», провод — base64 от «date|value|mac»; Decode проверяет MAC через subtle.ConstantTimeCompare и сроки («expired timestamp»); maxLength 4096; GenerateRandomKey не персистится — рестарт протухает все куки.

### Что дальше: токен вместо куки

Канон научился помнить пользователя. Трио context.go — Cookie, SetCookie, Cookies — оказалось тремя однострочниками над net/http с одним честным сюрпризом: невалидное имя молча не доходит до клиента. Кука темы на /api/v1/theme показала литерал &http.Cookie с Max-Age в проводе; сессия на /login, /me, /logout — мост echo-contrib/session, за которым стоит gorilla/sessions без единой подмены: Config.Store — это sessions.Store, и никакого своего хранилища echo-contrib не содержит. Карта Values, Registry с кэшем на запрос, CookieStore с его ключевыми парами и дефолтными Options, флеш-сообщения под _flash со стиранием при чтении, подпись securecookie с HMAC по «name|date|value» — весь конвейер сверен с исходниками v4.15.0, v0.18.0, gorilla/sessions v1.4.0 и securecookie v1.1.2.

Про песочницу честно, как во всём курсе: у курса Echo её нет — код исполняется локально, поэтому runner-ов здесь не будет; соберите канон go run main.go и прогоните цепочку curl: POST /login с JSON alice/qwerty-42, взгляд на Set-Cookie, затем GET /me с -b "user=..." — и тот же запрос без куки, чтобы увидеть is_new: true. Отдельно сломайте подпись: измените один символ значения куки user и убедитесь, что /me отдаёт новую сессию, а не ошибку — и объясните, почему это ровно та логика, которая написана в CookieStore.New.

Следующий, пятнадцатый урок курса echo — JWT-аутентификация: echo-jwt middleware, клеймы, роли, refresh-токены. Сессия держит состояние в куке и в карте Values; JWT несёт состояние в самом токене, подписанном SignedString, — и первые вопросы там будут про то, где такой токен хранить и что он, в отличие от подписанной куки, прочитать умеет. Навигация по курсу — на [странице курса Echo](/courses/echo/); форма, чьи ошибки заслуживают флеша, — в [уроке про формы и файлы](/courses/echo/lessons/13-formy-fajly/); параллельная реализация той же темы в fiber — в [уроке fiber про cookie и сессии](/courses/fiber/lessons/12-cookie-sessii/) — там своя session-механика v3, пригодится для сравнения подходов, но не как источник фактов об echo.

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

- В echo куки — три однострочника context.go v4.15.0: c.Cookie(name) делегирует Request.Cookie (промах — ErrNoCookie «http: named cookie not present», при нескольких одноимённых вернётся одна), c.SetCookie — http.SetCookie(c.Response(), cookie), c.Cookies — Request.Cookies(). Всё остальное — политика net/http.
- http.SetCookie молча пропускает невалидную куку: «Invalid cookies may be silently dropped» — тело if v := cookie.String(); v != "", а String() даёт пустую строку при невалидном имени. MaxAge<0 пишется в провод как «Max-Age=0»; MaxAge=0 — атрибута нет, кука сессионная.
- echo-contrib/session v0.18.0 — последний релиз под echo/v4: middleware только кладёт хранилище в c.Set("_session_store"), session.Get достаёт его тип-ассершном и зовёт store.Get; без middleware — ошибка «"_session_store" session store not found», без Store — panic на монтировании. Gorilla-совместимость честная: Config.Store — интерфейс gorilla sessions.Store {Get, New, Save}.
- Session — это ID (не для данных), Values map[interface{}]interface{} (gob), Options и IsNew. Registry кэширует сессии на запрос: повторный session.Get возвращает тот же *Session — «Get() registers and reuses the same decoded session after the first call».
- CookieStore.New: кука есть → securecookie.DecodeMulti; битая подпись даёт новую сессию И ошибку. Save → EncodeMulti → http.SetCookie с NewCookie; sess.Save обязателен до записи тела ответа. Ключи — парами: HMAC 32/64 байта + AES 16/24/32; ротация через несколько пар. Дефолт Options — SameSite None и Secure true: на http-локалке браузер куку не вернёт, нужны Lax и Secure=false; HttpOnly в дефолте false.
- Флеш-сообщения: AddFlash пишет в Values["_flash"], Flashes возвращает и стирает («Drop the flashes and return it»); стирание доезжает до клиента только через Save; повторный Flashes в том же запросе пуст. Удаление сессии — Options.MaxAge = -1: NewCookie ставит Expires = time.Unix(1, 0).
- Подпись — gorilla/securecookie: New(hashKey, blockKey), Encode «serializes, optionally encrypts, signs with a message authentication code, and finally encodes», MAC по «name|date|value», провод — base64 от «date|value|mac», проверка subtle.ConstantTimeCompare, потолок 4096 байт, срок 30 дней. GenerateRandomKey не персистится — рестарт с новым ключом делает все выпущенные куки нерасшифровываемыми.

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

Соберите канон урока: кука темы на /api/v1/theme, сессия на /login, /me, /logout с CookieStore и переопределёнными Options. Первое: пройдите цикл входа curl-ом — POST /login с JSON alice/qwerty-42, сохраните куку в jar (-c), GET /me (-b), зафиксируйте is_new: false и флеш «Вы вошли как alice», затем повторите /me — флеш исчез; объясните, какая строка исходника гарантирует исчезновение. Второе: разлогиньтесь и убедитесь, что Set-Cookie ответа содержит Expires в прошлом и Max-Age=0; сверьте с кодом NewCookie для MaxAge<0. Третье: удалите из main три строки переопределения Options и попробуйте цикл входа браузером (не curl-ом) по http://localhost:1323 — объясните по дефолту NewCookieStore, почему сессия каждый раз новая; верните строки. Четвёртое: перепишите куку pref на securecookie с двумя ключами (hashKey и blockKey) и проверьте три сценария: честное значение, подменённый хвост (400, «the value is not valid») и имя Decode, не совпадающее с именем Encode. Пятое: добавьте в login AddFlash с собственным ключом "notice" и прочитайте его sess.Flashes("notice") на /me, не задев _flash.

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

### Чем echo-contrib/session отличается от gorilla/sessions — что из чего импортировать?

gorilla/sessions — сама библиотека сессий: тип Session, карта Values, интерфейс Store и два хранилища (CookieStore, FilesystemStore). echo-contrib/session — тонкий мост для echo: middleware, который кладёт ваш gorilla-Store в контекст запроса, и хелпер session.Get(name, c), достающий его обратно. Импортируйте оба: session "github.com/labstack/echo-contrib/session" и gorilla "github.com/gorilla/sessions"; хранилище создаёт gorilla — session.NewCookieStore из echo-contrib — это алиас gorilla-конструктора. Совместимость честная: сигнатура принимает sessions.Store, поэтому любое gorilla-совместимое хранилище подходит без адаптеров.

### Почему сессия не сохраняется между запросами, хотя sess.Save вызван?

Проверьте четыре места по порядку. Первое: Save должен случиться до записи тела — после c.JSON заголовок уже ушёл. Второе: дефолтные Options CookieStore — Secure true и SameSite None — по http браузер не отправит куку обратно; для локалки нужны SameSite Lax и Secure false (curl с -b при этом работает и маскирует проблему). Третье: ключ шифрования не должен генерироваться заново на каждый старт — иначе подпись старой куки не сойдётся и CookieStore.New вернёт новую сессию с ошибкой. Четвёртое: не меняйте имя сессии между вызовами — Registry кэширует по имени, и две сессии с разными именами — это две разные куки.

### Когда хватит простой куки с securecookie, а когда нужна сессия с CookieStore?

Подписанная кука через securecookie.New — для одного-двух примитивных значений: тема, выбранный язык, «показать приветствие». Вы получаете подпись, срок и потолок 4096 байт без дополнительных зависимостей. Session с CookieStore нужна, когда значений много или они структурные: карта Values с gob, флеш-сообщения, опции на уровне сессии, ротация ключей и готовый механизм замены хранилища — FilesystemStore или свой Store, когда данные больше не должны уезжать в браузер. Помните: cookie-сессия не прячет данные без ключа шифрования — без blockKey значение читаемо клиентом, хотя и неподделываемо.
