---
title: "Аутентификация: JWT и сессии как middleware"
description: "Аутентификация go на middleware chi: проверяем JWT один раз, golang-jwt/v5, Bearer в Authorization, пользователь в контексте, сессии против JWT и Group."
course: "Chi: роутер для Go HTTP-сервисов"
url: "https://golangmaster.ru/courses/chi/lessons/21-auth-middleware/"
published: "2026-09-19"
updated: "2026-09-27"
minutes: 18
---

# Аутентификация: JWT и сессии как middleware

*Курс «Chi: роутер для Go HTTP-сервисов», урок 21 из 25. ~18 минут. Обновлено 27.09.2026. Источник: https://golangmaster.ru/courses/chi/lessons/21-auth-middleware/*

Аутентификация go-сервиса чаще всего размазана по хендлерам: в каждой ручке свой парсинг токена, и однажды в новой ручке проверка забудется. Здесь chi jwt-аутентификация собирается в одном middleware: токен проверяется один раз на входе, пользователь кладётся в контекст, дальше — структура JWT, golang-jwt/v5, Bearer-заголовок, сессии против JWT и Group для защищённых маршрутов.

Сервис заметок на chi вырос до пятнадцати хендлеров, и проверка токена в нём жила в каждом: четыре строки — достань заголовок Authorization, распарси JWT, свер exp, положи userID в локальную переменную. Копипаст шелестел от ручки к ручке, и однажды в новой ручке редактирования профиля проверка не приехала: ручка работала, тест на неё писался без токена, а ревьюер заметил дыру через неделю после релиза. Четыре строки в пятнадцати местах — это пятнадцать шансов забыть одну. Чинится переносом проверки на слой выше: middleware в chi стоит перед каждым хендлером ветки, токен разбирается один раз, а пользователь кладётся в контекст запроса. В этом уроке соберём аутентификацию именно так — на golang-jwt/v5, Bearer-заголовке и Group из урока про Route и Group.

### Аутентификация go: токен проверяется один раз в middleware

Middleware в chi — функция вида func(http.Handler) http.Handler, как в [уроке про собственные middleware](/courses/chi/lessons/12-own-middleware/): она получает next, оборачивает его и возвращает новый хендлер. Аутентификация go-сервиса — идеальная работа именно для неё: алгоритм одинаков для всех ручек, а ветвится редко. Middleware делает три вещи: достаёт токен из запроса, проверяет его и кладёт результат в контекст — request-scoped хранилище, о котором подробно говорили в [уроке про context](/courses/chi/lessons/10-context/). Дальше хендлеры достают пользователя оттуда и не знают ни про заголовки, ни про подписи. Здесь важно не перепутать две вещи: аутентификация отвечает на вопрос «кто ты» — она в middleware; авторизация отвечает «что тебе можно» — «этот пользователь админ» — и это отдельная проверка на конкретной ручке или ветке. Смешивать их в одном слое рано: сначала сервис узнаёт, кто пришёл, потом решает, что можно.

### RequireAuth: проверка токена и пользователь в контексте

```go
type userKey struct{}

func RequireAuth(next http.Handler) http.Handler {
        return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
                header := r.Header.Get("Authorization")
                const prefix = "Bearer "
                if !strings.HasPrefix(header, prefix) {
                        w.Header().Set("WWW-Authenticate", "Bearer realm=\"api\"")
                        http.Error(w, "требуется токен", http.StatusUnauthorized)
                        return
                }
                tokenString := strings.TrimPrefix(header, prefix)

                token, err := jwt.Parse(tokenString, func(t *jwt.Token) (any, error) {
                        if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok {
                                return nil, fmt.Errorf("неожиданный алгоритм: %v", t.Header["alg"])
                        }
                        return []byte(secret), nil
                })
                if err != nil || !token.Valid {
                        http.Error(w, "токен не прошёл проверку", http.StatusUnauthorized)
                        return
                }

                claims := token.Claims.(jwt.MapClaims)
                ctx := context.WithValue(r.Context(), userKey{}, claims["sub"])
                next.ServeHTTP(w, r.WithContext(ctx))
        })
}
```

Проверка живёт в одном месте: нет заголовка или подпись не сошлась — 401 и выход, до хендлера дело не доходит. userKey — пустая структура-тип, как RouteCtxKey в chi: чужой пакет не прочтёт и не перезапишет значение случайно. Хендлеры достают пользователя через r.Context().Value(userKey{}) и не знают про JWT. Проверка алгоритма внутри keyfunc — защита от подмены alg, о ней ниже.

### Проверка: зачем middleware

**Вопрос:** Пятнадцать хендлеров и один RequireAuth на ветке. Что меняется для хендлеров?

- ✅ Они получают уже проверенного пользователя из контекста и не трогают заголовки
- Они по-прежнему парсят токен, но быстрее — middleware кэширует JWT между запросами
- Хендлеры обязаны повторно проверять подпись: middleware только читает заголовок
- Ничего: middleware в chi отрабатывает после хендлеров

Middleware стоит в цепочке до хендлеров: RequireAuth отрабатывает раньше любой ручки ветки, и проверка выполняется один раз на запрос. Пользователь кладётся в контекст — хендлеры остаются в неведении про механизм аутентификации. Кэшировать JWT между запросами middleware не может: контекст живёт ровно один запрос.

### JWT изнутри: header.payload.signature и подпись

JWT — три части в base64url, склеенные точками. Header рассказывает, чем подписано: alg HS256 для HMAC или RS256 для RSA, плюс typ JWT. Payload — claims: sub с идентификатором пользователя, exp с моментом истечения, iat с временем выдачи; туда можно класть свои поля — роль, email. Signature — HMAC-SHA256 от первых двух частей секретным ключом. Подпись гарантирует одно: после выдачи ни байт в header и payload не менялся — изменился символ, сверка рухнула. А вот шифрования в JWT нет: base64url — кодирование, а не защита; любой, у кого есть токен, читает payload как открытый текст — это удобно видеть на jwt.io. Отсюда правило: секретов и личных данных в claims быть не должно. Классическая атака на токены — подмена alg: клиент шлёт header с alg none и пустой подписью в надежде, что библиотека примет. В golang-jwt/v5 это закрыто двумя способами — проверкой типа в keyfunc, как в блоке выше, и опцией WithValidMethods, о которой ниже.

### golang-jwt/v5: выдача токена и ParseWithClaims

```go
import "github.com/golang-jwt/jwt/v5"

// выдача: подписываем RegisteredClaims с истечением через 15 минут
claims := jwt.RegisteredClaims{
        Subject:   userID,
        IssuedAt:  jwt.NewNumericDate(time.Now()),
        ExpiresAt: jwt.NewNumericDate(time.Now().Add(15 * time.Minute)),
}
signed, err := jwt.NewWithClaims(jwt.SigningMethodHS256, claims).
        SignedString([]byte(secret))

// проверка: ParseWithClaims + keyfunc + белый список алгоритмов
parsed, err := jwt.ParseWithClaims(tokenString, &jwt.RegisteredClaims{},
        func(t *jwt.Token) (any, error) { return []byte(secret), nil },
        jwt.WithValidMethods([]string{"HS256"}))
if err != nil {
        // errors.Is(err, jwt.ErrTokenExpired) и другие причины
        http.Error(w, "токен недействителен", http.StatusUnauthorized)
        return
}
sub := parsed.Claims.(*jwt.RegisteredClaims).Subject
```

RegisteredClaims — готовый набор полей стандарта: iss, sub, aud, exp, nbf, iat. exp задаётся NumericDate, и v5 сверяет его сам: просроченный токен упадёт с ErrTokenExpired без единой строки проверки. WithValidMethods закрывает подмену alg: парсер примет только HS256. ParseWithClaims вместо Parse берут, чтобы достать claims типом, а не словарём MapClaims. Проверьте оба вызова локальным Go: соберите токен и распарсите его обратно.

### Проверка: просроченный токен

**Вопрос:** exp в прошлом. Что вернёт jwt.ParseWithClaims для такого токена?

- ✅ err с ErrTokenExpired, и parsed.Valid останется false
- err == nil: exp проверяется приложением вручную
- панику: парсер не умеет сравнивать даты
- err == nil, но claims окажутся пустыми

v5 валидирует exp/nbf/iat сам: при exp в прошлом парсер вернёт ошибку с ErrTokenExpired — по исходникам её текст «token is expired» — и Token.Valid останется false. Причины разделяются через errors.Is: на просрочку клиенту принято отвечать подсказкой запустить refresh, а не общим 401.

Токен приезжает в заголовке Authorization по схеме Bearer: «Authorization: Bearer eyJhbGciOiJIUzI1NiJ9...» — слово Bearer, пробел, сам токен. Middleware срезает префикс через strings.HasPrefix и strings.TrimPrefix и работает с чистой строкой; нет префикса — сразу 401. RFC 6750 требует, чтобы ответ 401 нёс заголовок WWW-Authenticate с объяснением: Bearer realm=«api» — заголовка не было вовсе, Bearer error=«invalid_token» — токен есть, но проверку не прошёл. На этот заголовок смотрят машины: HTTP-библиотеки мобильных клиентов решают по нему, молча разлогинивать или запускать refresh. Тело ответа — для человека, его менять безопасно; пропустить WWW-Authenticate — легко, потому что в браузере отсутствие не заметно. Мы положим заголовок прямо в RequireAuth, в обе ветки отказа.

> **Секрет подписи в коде — ключ утёк с репозиторием.** const secret = «super-secret-2024» в main.go — и через год репозиторий показывают фрилансеру, открывают для задачи или копируют в форк. Ключ подписи теперь у всех: любой желающий выдаёт валидный токен с любым sub и ходит чужим пользователем, при этом криптография ниже безупречна — HS256 честно подписан. Лечение скучное: секрет из переменной окружения или секрет-менеджера, сканер секретов вроде gitleaks в CI и запланированная ротация — два ключа параллельно, старый доживает до конца exp.

### Сессии против JWT: как работают обе схемы

Сессии — схема постарше и проще. На логине сервер создаёт запись: в redis или БД лежит, кто это и что ему можно, а клиенту в HttpOnly cookie отдаётся session id. Каждый запрос сервер берёт id из куки и делает lookup: жива ли запись. JWT переворачивает хранение: состояние едет в самом токене, сервер держит только секрет подписи и проверяет целостность — ни redis, ни lookup. Отсюда все плюсы и минусы, которые сводим в таблицу ниже. Отдельный вопрос — где клиент хранит токен: cookie HttpOnly недоступна JavaScript, а токен в localStorage читается любым XSS. Для браузера cookie с HttpOnly и SameSite по-прежнему безопаснее; заголовок Authorization удобнее мобильным приложениям и междоменным API. Это не конкуренция на смерть, а выбор точки хранения состояния: в redis или в подписанной строке у клиента.

### JWT против сессий

| Критерий | JWT | Сессии |
| --- | --- | --- |
| Отзыв и logout | живёт до exp; честно — только denylist, и это снова состояние | удаление записи в redis — мгновенно и на всех инстансах |
| Хранение на сервере | нет: stateless, только секрет подписи | redis или БД, lookup на каждый запрос |
| Вес запроса | токен в заголовке — сотни байт, растут с claims | маленький session id |
| Мобильные и междоменные клиенты | заголовок Authorization — без CORS-игр с куками | куки требуют SameSite и доменной логики |
| Криптография | проверка подписи на каждый запрос | нет: доверяем своему redis |
| Главный риск | утёкший секрет или подмена alg | угон cookie, XSS |

### Что ответит сервер на подделку

```go
// клиент взял валидный токен и поменял один байт в payload:
// sub с "user-1" на "admin". Подпись пересчитать не умеет.

token, err := jwt.ParseWithClaims(forged, &jwt.RegisteredClaims{}, keyfunc,
        jwt.WithValidMethods([]string{"HS256"}))

// RequireAuth: err != nil → ?
```

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

- ✅ err != nil: подпись не сошлась — ErrTokenSignatureInvalid, и RequireAuth отдаст 401
- err == nil: подпись покрывает только header, payload свободен для правок
- err == nil, но в claims будет мусор: v5 не сверяет подпись для HS256
- панику: ParseWithClaims не переживает битые токены

Подпись — HMAC от точной пары header.payload: сменился байт — сверка падает. v5 вернёт ошибку с ErrTokenSignatureInvalid — её текст «token signature is invalid» — и middleware отдаст 401: клиент останется user-1. Payload нельзя подделать без секрета, а секрет нельзя достать из токена — он только у сервера.

### Публичные и защищённые маршруты: chi jwt middleware

Защита нужна не всему сервису: /healthz должен пинговаться без токена, /login и /register — принимать анонимов. В chi jwt-аутентификация вешается точечно через Group из [урока про Route и Group](/courses/chi/lessons/07-groups-routes/): Group возвращает inline-роутер на том же дереве без префикса, и r.Use внутри действует только на ветку. Публичные маршруты остаются на корне, защищённые собираются в Group с RequireAuth — chi jwt middleware здесь собирается один раз, а не копипастится в хендлеры. Для ролей поверх RequireAuth добавляется RequireAdmin из урока про собственные middleware: аутентификация и авторизация снова в разных слоях. Ошибка новичка — повесить RequireAuth через r.Use на корень: тогда 401 начнёт получать и /healthz, по которому мониторинг решит, что сервис умер.

### Group: публичный корень и защищённая ветка

```go
r := chi.NewRouter()
r.Use(middleware.RequestID)
r.Use(middleware.Logger)

// публичные: без токена
r.Get("/healthz", healthz)
r.Post("/login", login(secret))
r.Post("/register", register(store))

// защищённые: RequireAuth до хендлеров ветки
r.Group(func(r chi.Router) {
        r.Use(RequireAuth)
        r.Get("/notes", listNotes(store))
        r.Post("/notes", createNote(store))
        r.Get("/notes/{id}", getNote(store))
})
```

Публичные ручки зарегистрированы до и вне Group — RequireAuth их не накрывает. Внутри группы порядок тот же, что в корне: сначала Use, потом маршруты. Хендлеры достают пользователя из контекста, который положил RequireAuth. Проверьте локальным Go: запустите сервис, снимите токен с /login и сравните curl без заголовка и с ним — 401 с WWW-Authenticate против 200.

> **401 без WWW-Authenticate сломал мобильный клиент.** Отдали 401 с телом «нет токена» и без WWW-Authenticate — в браузере всё работало, а мобильный клиент лёг: его HTTP-библиотека решала по RFC 6750, запускать ли refresh, читая заголовок, а заголовка не было; интерсептор падал и разлогинивал пользователя на каждом истёкшем токене. Чинилось одной строкой — WWW-Authenticate: Bearer error=«invalid_token» в RequireAuth. Машина-клиент читает заголовки, а не тело ответа: как клиенты вообще разбирают ответы HTTP — тема [урока про HTTP-клиент](/courses/go-advanced/lessons/22-http-klient/).

### Спор: stateless JWT против server-side сессий

Партия JWT: масштабирование без липких сессий — любой инстанс проверит подпись одинаково, redis не в критическом пути, мобильные клиенты довольны. Минус вы уже знаете: отзыв. Токен жив до exp, «выйти» на сервере негде; честный logout требует denylist отозванных токенов — а это redis, lookup и конец stateless-рая. Обходной путь — короткий exp в 5–15 минут плюс refresh-токен: ущерб от украденного ограничен, но refresh уже хранит состояние на сервере, так что полностью stateless не остаётся нигде. Партия сессий: отзыв мгновенный, cookie HttpOnly, клиенту нечем распорядиться; минус — lookup на каждый запрос и redis в критическом пути. Моя граница такая: SPA плюс мобильные плюс междоменное API — JWT с коротким exp и refresh; админка, деньги и всё, где logout обязан срабатывать мгновенно, — сессии. И честная треть: для типового сервиса в браузере сессии проще, а разницу в масштабировании вы не увидите до очень серьёзного трафика.

### Чек-лист: JWT в chi без дыр

- Секрет подписи — из окружения или секрет-менеджера, не из кода; ротация запланирована заранее.
- Алгоритм проверяется всегда: WithValidMethods у парсера или явный type assertion в keyfunc.
- Короткий exp — 5–15 минут — и refresh-токен; RegisteredClaims с ExpiresAt, а не ручные сверки.
- Каждый 401 несёт WWW-Authenticate: Bearer realm=... или error=invalid_token — по нему клиенты решают судьбу сессии.
- Пользователь кладётся в контекст через приватный тип-ключ, а не строку — как RouteCtxKey в chi.
- Мгновенный отзыв нужен — это denylist или сессии: stateless JWT отзывается с болью.

### Соберите проверку токена

Восстановите три вызова проверки: разбор с готовыми claims, поле валидности и константу 401.

```go
token, err := jwt.___(tokenString, &jwt.RegisteredClaims{}, keyfunc,
        jwt.WithValidMethods([]string{"HS256"}))
if err != nil || !token.___ {
        w.Header().Set("WWW-Authenticate", "Bearer realm=api")
        http.Error(w, "нет доступа", http.___)
        return
}
```

Подсказка: С готовыми claims разбирает ParseWithClaims; после сверки подписи поле token.Valid становится true; константа 401 — http.StatusUnauthorized.

Правильные ответы в пропусках: `ParseWithClaims`, `Valid`, `StatusUnauthorized`

### Соберите скелет RequireAuth

Расставьте строки middleware в правильном порядке: от функции-обёртки до ответа 401 на не прошедших проверку.

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

1. `func RequireAuth(next http.Handler) http.Handler {`
2. `return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {`
3. `tokenString := strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ")`
4. `token, err := jwt.ParseWithClaims(tokenString, &jwt.RegisteredClaims{}, keyfunc)`
5. `if err != nil || !token.Valid {`
6. `http.Error(w, "нет доступа", http.StatusUnauthorized)`

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

- **header.payload.signature** — Три части JWT в base64url: header с alg и typ, payload с claims (sub, exp, iat), подпись HMAC или RSA. Подпись гарантирует целостность, но payload не шифруется — его читает любой владелец токена.
- **keyfunc** — Функция в jwt.Parse/ParseWithClaims: получает распарсенный токен и возвращает ключ проверки. Здесь же проверяют алгоритм — type assertion на *jwt.SigningMethodHMAC, чтобы не пропустить подмену alg.
- **jwt.RegisteredClaims** — Готовые claims v5: iss, sub, aud, exp (*NumericDate), nbf, iat, ID. exp проверяется автоматически: просрочка даёт ошибку с ErrTokenExpired («token is expired»), token.Valid остаётся false.
- **Bearer** — Схема заголовка Authorization: «Bearer <токен>». По RFC 6750 ответ 401 обязан нести WWW-Authenticate — по нему клиентские библиотеки решают, разлогинивать или запускать refresh.
- **Сессия** — session id в HttpOnly cookie, состояние на сервере в redis или БД, lookup на каждый запрос. Отзыв мгновенный; основной риск — угон cookie и XSS.
- **Отзыв JWT** — Токен жив до exp; честный logout — denylist отозванных, и это снова состояние на сервере. Рабочая схема — короткий exp плюс refresh-токен.

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

- Аутентификация go — это middleware: токен парсится один раз на входе, пользователь кладётся в контекст, хендлеры не знают про заголовки.
- JWT = header.payload.signature: подпись гарантирует целостность claims, но payload читается всеми — секретов в нём нет.
- golang-jwt/v5: ParseWithClaims с keyfunc и WithValidMethods; exp из RegisteredClaims проверяется автоматически, просрочка — ErrTokenExpired.
- Bearer-токен живёт в Authorization; 401 обязан нести WWW-Authenticate — по нему клиенты решают, запускать ли refresh.
- Сессии: состояние на сервере, отзыв мгновенный; JWT: stateless, но отзывается через denylist и refresh — то есть снова с состоянием.
- Публичные маршруты — на корне, защищённые — в r.Group с r.Use(RequireAuth), как в уроке 07.
- Секрет подписи — из окружения, не из кода: утёкший ключ делает любой sub вашим пользователем.

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

Соберите chi-сервис из двух зон: публичные /healthz, /login и /register на корне, защищённая Group с RequireAuth для /notes. Выдавайте HS256-токен с exp на 15 минут через RegisteredClaims, проверяйте ParseWithClaims с WithValidMethods, кладите sub в контекст и добавьте WWW-Authenticate к каждому 401. Проверьте curl-ом: без токена, с токеном и с просроченным.

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

### JWT или сессии, если фронтенд один и живёт в браузере?

Обычно сессии: cookie HttpOnly с SameSite безопаснее localStorage, отзыв мгновенный, а масштабирование до сессий доезжает позже, чем кажется. JWT оправдан, когда клиенты разные — мобильные приложения, другие сервисы, междоменные вызовы, — либо когда лишний lookup в redis уже болит на нагрузке.

### Где клиенту хранить JWT?

В памяти SPA плюс refresh-токен в HttpOnly cookie: access-токен XSS не заберёт, а сессия переживает перезагрузку страницы. localStorage удобен, но любой внедрённый скрипт читает его целиком — это типичная точка входа при утечке через сторонний скрипт.

### Опасна ли подмена alg на none?

Для golang-jwt/v5 — нет, если передана WithValidMethods или алгоритм проверяется в keyfunc: парсер примет только заявленные методы. Уязвимость была в старых библиотеках, принимавших неподписанные токены. Правило одно: алгоритм из самого токена не используется без белого списка.
