---
title: "Аутентификация JWT"
description: "JWT в gin: golang-jwt/v5, выдача и проверка токена, claims и подпись HS256, гвард Authorization Bearer, refresh-токены, 401 против 403 и секрет из env."
course: "Gin: самый популярный веб-фреймворк Go"
url: "https://golangmaster.ru/courses/gin/lessons/15-jwt/"
published: "2026-09-19"
updated: "2026-09-21"
minutes: 24
---

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

*Курс «Gin: самый популярный веб-фреймворк Go», урок 15 из 25. ~24 минут. Обновлено 21.09.2026. Источник: https://golangmaster.ru/courses/gin/lessons/15-jwt/*

Сессии из прошлого урока знают пользователя, но помнят его на сервере: кластеру нужен общий redis, а мобильному клиенту — cookie jar. JWT переворачивает схему: данные о пользователе и срок годности подписываются сервером и едут в самом токене, поэтому «кто это» проверяется без единого обращения к хранилищу. Разберём анатомию токена — header, payload, signature — и почему base64url не шифрование, соберём выдачу через golang-jwt/jwt/v5 и claims на jwt.RegisteredClaims, напишем middleware-гвард для группы /api/v1 из [урока про группы](/courses/gin/lessons/10-gruppy/) с Authorization Bearer и честными 401 против 403, прикрутим refresh-токены и поговорим про секреты, HS256 против RS256 и хранение токена на клиенте. Звенья и Abort берем из [урока про кастомные middleware](/courses/gin/lessons/07-kastomnye-middleware/), валидацию логина — из [урока про validator](/courses/gin/lessons/09-validaciya/), а сессии из [урока про cookie](/courses/gin/lessons/14-cookie-sessii/) останутся для веб-интерфейса: один сервер, две схемы аутентификации.

### gin jwt: stateless-альтернатива сессиям

Сессия из прошлого урока — разговор через камеру хранения: пользователь получает билетик, всё содержимое сумки лежит на сервере. Надёжно, но у схемы есть цена: сервер обязан помнить каждую сессию, кластер обязан помнить их одинаково, а клиентам без cookie — мобильным приложениям, другим бэкендам, CLI — приходится имитировать cookie jar. gin jwt — вторая большая схема аутентификации, stateless: всё содержимое «сумки» подписывается и отдаётся клиенту целиком в токене. Сервер хранит только секрет подписи — одна строка байтов, одинаковая для всех реплик; каждый узел кластера проверяет токен самостоятельно, redis не нужен, рестарт ничего не стирает. Токен — это три строки base64url, склеенные точками: header.payload.signature. Header описывает алгоритм подписи, payload — данные (claims): кто пользователь, какая роль, до какого времени годен, когда выдан. Signature — подпись первых двух строк ключом сервера: изменить payload, не пересчитав подпись, нельзя, а пересчитать её без ключа — нельзя в принципе. Заметим сразу, что нужно, чтобы не обмануться: подпись — это не шифрование. Токен кодируется, а не зашифрован — любой, кто его увидел, прочитает содержимое; секрет не даёт ИЗМЕНИТЬ, но не даёт СКРЫТЬ. Отсюда всё богатство уроков ниже: что можно класть в claims, что нельзя, и почему логаут с stateless-токеном — отдельная инженерная задача. Фраза «jwt авторизация go» в поиске обычно означает ровно три операции — выдать, проверить, обновить — и все три разберём на нашем трекере: /api/v1/todos станет доступен только с валидным токеном.

### Анатомия токена: header, payload, signature

Заголовок — маленький JSON вроде {"alg":"HS256","typ":"JWT"}: alg — алгоритм подписи, typ — тип. Полезная нагрузка — JSON с claims, и часть имён тут стандартизирована RFC 7519: iss (issuer) — кто выдал токен, sub (subject) — о ком он, aud (audience) — для какого потребителя, exp (expiration) — время смерти в секундах unix, iat (issued at) — когда выдан, nbf (not before) — не принимай раньше, jti (JWT ID) — уникальный идентификатор токена, через который потом ищут и отзывают конкретные токены. Поверх стандартных кладут свои: user_id, role, версия прав доступа. Всё это — слова, которые сервер сам написал и сам подписал: вот почему middleware готов верить им без похода в базу. Третья часть — подпись: HMAC-SHA256(header.payload, secret) для HS256 или RSA-ECDSA-подпись для RS256/ES256. Проверка сервера элементарна: пересчитать подпись первых двух частей своим ключом и сравнить. Разберём три следствия, на которых ловят новички. Первое: base64url — это кодирование, декодер строится в три строки, сайт jwt.io показывает payload наглядно — не кладите в токен ничего, что не готовы показать. Второе: «alg»: «none» — историческая атака: некоторые старые библиотеки принимали токены без подписи, если в header написано none; современная golang-jwt отклоняет такое, но проверять ожидаемый алгоритм всё равно нужно явно — увидим в keyfunc. Третье: confusion HS256/RS256 — если сервер подписывает RS256 приватным ключом, а в keyfunc наивно возвращает тот же байтовый секрет, атакующий переклеит alg на HS256 и подпишет токен публичным ключом, который и так известен; вывод — проверять t.Method жестко, а не доверять header. exp, iat и nbf библиотека проверяет сама, если claims реализуют нужные методы, — об этом дальше.

### Выдача: golang-jwt/jwt/v5 и RegisteredClaims

```go
package main

import (
        "fmt"
        "net/http"
        "os"
        "time"

        "github.com/gin-gonic/gin"
        "github.com/golang-jwt/jwt/v5"
)

type UserClaims struct {
        UserID int64  `json:"user_id"`
        Role   string `json:"role"`
        jwt.RegisteredClaims
}

func issueToken(userID int64, role string, ttl time.Duration) (string, error) {
        now := time.Now()
        claims := UserClaims{
                UserID: userID,
                Role:   role,
                RegisteredClaims: jwt.RegisteredClaims{
                        Subject:   fmt.Sprintf("%d", userID),
                        Issuer:    "goma-tracker",
                        IssuedAt:  jwt.NewNumericDate(now),
                        ExpiresAt: jwt.NewNumericDate(now.Add(ttl)),
                        ID:        fmt.Sprintf("%d-%d", userID, now.UnixNano()),
                },
        }
        return jwt.NewWithClaims(jwt.SigningMethodHS256, claims).
                SignedString([]byte(os.Getenv("JWT_SECRET")))
}
```

go get github.com/golang-jwt/jwt/v5. Свой тип claims встраивает jwt.RegisteredClaims — вместе со всеми стандартными методами, которые нужны интерфейсу Claims. jwt.NewWithClaims собирает токен, SignedString подписывает HS256-ключом и возвращает строку из трёх частей. TTL передаётся параметром: 15 минут для access, сутки для refresh — функция одна. ID из UnixNano — дешёвый jti без внешних зависимостей.

### Пароли и LoginInput: JWT начинается до токена

Прежде чем выдавать токен, надо проверить, кому выдаёшь, — и JWT тут ни при чём: пароли — отдельная дисциплина. Хендлер POST /api/v1/login принимает LoginInput со структурой из урока про binding и валидацию: Username с тегом binding:"required,min=3" и Password с binding:"required,min=8" — validator отсечёт пустышки ещё до похода в хранилище пользователей. Пароль в базе хранится хэшем, а не текстом: при регистрации пользователь проходит bcrypt.GenerateFromPassword, при логине — bcrypt.CompareHashAndPassword, и совпадение говорит «это он» без того, чтобы сервер хоть раз видел plaintext. Никогда не кладите пароль или его хэш в claims: payload читается всеми, кто увидел токен, и живет в нем до exp. Ошибка аутентификации отвечайте единым «логин или пароль неверны» со статусом 401: не уточняйте, что именно неверное, — не дарите переборщику подсказок. Тот же принцип тишины — в логах: логируйте факт выдачи с jti и user_id, но не сам токен и не пароль; токен, попавший в лог, становится легитимным ключом до exp. И держите в голове нагрузку: /login — единственный эндпоинт, где plaintext пароля существует, поэтому он же — любимая цель перебора; ограничение частоты запросов на него поставит middleware из урока про rate limiting, а здесь запомним: выдача токена — это проверка пароля, сборка claims и подпись, всё остальное — декорации.

### Проверка: кто прочитает role из токена

**Вопрос:** Клиент получил токен с claims {"user_id":1,"role":"user"}. Что он может сделать с этой ролью?

- Прочитать и изменить на admin: base64url редактируется тривиально
- ✅ Прочитать, но не изменить: подпись связывает payload с секретом сервера
- Ни прочитать, ни изменить: payload зашифрован alg-ом из header
- Не увидеть ни того, ни другого: токен — непрозрачная строка для клиента

base64url — кодирование, а не шифрование: любой декодер (и jwt.io) покажет payload целиком. Изменить же payload без пересчёта подписи нельзя, а подпись считается секретом сервера — чужой клиент её не воспроизведёт. Отсюда правило: в claims кладут то, что не жалко показать, и проверяют роли на сервере, а не верят токену вслепую — если, конечно, он подписан.

### Проверка: ParseWithClaims и keyfunc

Обратная операция — jwt.ParseWithClaims(tokenStr, &UserClaims{}, keyfunc): библиотека режет строку по точкам, декодирует header и payload, зовёт ваш keyfunc за ключом проверки и сверяет подпись, а заодно валидирует exp, iat и nbf, если они заполнены. Вся логика доверия живёт в keyfunc — и это место, где ставят два замка. Первый — алгоритм: func(t *jwt.Token) (any, error) { if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok { return nil, fmt.Errorf("неожиданный alg: %v", t.Header["alg"]) }; return secret, nil } — токен с другим alg отвергается до сверки подписи, и «alg»: «none» с HS256/RS256-confusion отсекаются одним if-ом. Второй — сам ключ: для HS256 это []byte того же JWT_SECRET, что подписывал. Возврат — пара (*jwt.Token, error), и работа с ошибкой здесь дисциплинирует: ошибки v5 агрегированы и различаются через errors.Is — jwt.ErrTokenExpired для протухшего exp, jwt.ErrTokenNotValidYet для nbf, jwt.ErrTokenSignatureInvalid для подделки, jwt.ErrTokenMalformed для строки без двух точек. Ответ клиенту по этим ошибкам одинаковый — 401, но текст разный: на Expired вежливо подсказывают refresh, на Signature ничего не объясняют. Проверять token.Valid необязательно: если err == nil, токен жив и подписан; claims уже заполнились в вашей структуре — UserClaims, в которую ParseWithClaims декодировал payload, готова к чтению. Для внутреннего кода удобно вынести keyfunc в переменную verifyKey и переиспользовать в гварде и в refresh-эндпоинте — один источник истины про алгоритм и ключ, ноль расхождений.

### Гвард Auth(): Bearer, 401 и c.Set

```go
var verifyKey = func(t *jwt.Token) (any, error) {
        if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok {
                return nil, fmt.Errorf("неожиданный alg: %v", t.Header["alg"])
        }
        return []byte(os.Getenv("JWT_SECRET")), nil
}

func Auth() gin.HandlerFunc {
        return func(c *gin.Context) {
                header := c.GetHeader("Authorization")
                if !strings.HasPrefix(header, "Bearer ") {
                        c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{
                                "error":   "UNAUTHORIZED",
                                "message": "ожидается Authorization: Bearer <token>",
                        })
                        return
                }
                claims := &UserClaims{}
                _, err := jwt.ParseWithClaims(strings.TrimPrefix(header, "Bearer "), claims, verifyKey)
                if err != nil {
                        if errors.Is(err, jwt.ErrTokenExpired) {
                                c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{
                                        "error":   "TOKEN_EXPIRED",
                                        "message": "обновите токен через POST /api/v1/token/refresh",
                                })
                                return
                        }
                        c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "UNAUTHORIZED"})
                        return
                }
                c.Set("claims", claims)
                c.Next()
        }
}

api := r.Group("/api/v1")
api.Use(Auth())
api.GET("/todos", listTodos)
api.POST("/todos", createTodo)
```

Транспорт — заголовок Authorization со схемой Bearer и пробелом. AbortWithStatusJSON обрывает цепочку — механика из урока про кастомные middleware: до хендлера запрос не дойдёт. c.Set("claims", claims) кладёт данные в контекст; хендлеры ниже достают их через c.MustGet("claims").(*UserClaims). api.Use(Auth()) защищает все маршруты группы сразу — группы из урока про роуты.

### Проверка: 401 против 403

**Вопрос:** Токен валиден, claims.Role = "user", а маршрут требует роль admin. Какой статус правильный?

- 401 Unauthorized: клиент должен пройти аутентификацию заново
- ✅ 403 Forbidden: аутентификация прошла, прав не хватает
- 400 Bad Request: роль в токене не совпала с ожидаемой
- 404 Not Found: маршрут не показываем тем, кому нельзя

401 говорит «я не знаю, кто ты» — токена нет, он битый или протух, и по стандарту с ним идёт WWW-Authenticate: Bearer. 403 говорит «я тебя знаю, но нельзя» — это про авторизацию, повторный логин не поможет. 404-скрытие маршрутов — отдельная стратегия против разведки, но для обычного API она запутывает клиента без пользы.

### Refresh-токены: короткий access, долгий refresh

Жизненный цикл access-токена — компромисс между безопасностью и удобством. Живёт 15 минут: украденный токен умрёт быстро, но и пользователь будет пере-логиниваться каждые четверть часа — непростимо. Живёт неделю: удобно, но кража открывает неделю доступа, а отозвать stateless-токен нельзя. Стандартное решение — пара токенов: короткий access на 5-15 минут для каждого запроса к API и долгий refresh на дни-недели, который живёт в одной цели — получить новый access. POST /api/v1/token/refresh принимает refresh, проверяет той же verifyKey (отдельный claims-тип с типом в payload поможет не спутать пару), убеждается, что это refresh, а не access, и выдаёт свежий access; пароль заново не спрашивается. Храните refresh серьёзнее access: HttpOnly cookie из урока про cookie вместо localStorage или серверная запись. Ротация refresh — каждый refresh-запрос выдает и новый refresh, а старый инвалидирует; для этого нужен jti и хранилище (redis из прошлого урока) — и вы снова stateful, но только в одном месте. Logout с JWT — признание слабости stateless: токен подписан и жив до exp, сервер «забыть» его не может. Рабочие ответы: короткий exp как главный лимит вреда; denylist по jti — проверяете в гварде, не в списке ли отозванных, снова нужно хранилище; поле token_version в claims, инкрементируемое при смене пароля, — инвалидирует все токены пользователя разом. Для нашего трекера достаточно exp в 15 минут и refresh на сутки: утечка жёстко ограничена по времени, а инфраструктура отзыва не нужна.

### Протухший токен в гварде

```go
// access выдан 16 минут назад при exp 15 минут
c.Request.Header.Set("Authorization", "Bearer "+expiredToken)

api.GET("/todos", func(c *gin.Context) {
        claims := c.MustGet("claims").(*UserClaims)
        c.JSON(http.StatusOK, gin.H{"user_id": claims.UserID})
})
```

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

- Паника interface conversion в хендлере: claims не положили
- ✅ 401 с TOKEN_EXPIRED: ParseWithClaims вернул ErrTokenExpired, AbortWithStatusJSON оборвал цепочку
- 200: библиотека допускает небольшую задержку после exp
- 500: jwt.Parse паникует на просроченной подписи

exp валидируется при ParseWithClaims: просроченный токен даёт ошибку, errors.Is различает jwt.ErrTokenExpired, и гвард отвечает 401 с подсказкой про refresh. c.AbortWithStatusJSON останавливает цепочку, до хендлера с MustGet дело не доходит — паника не случится. Никаких «допусков»: время сверяется с точностью до секунды, clock skew регулируется опцией jwt.WithLeeway, если она вам нужна.

### Секрет и алгоритмы: HS256 против RS256

Секрет HS256 — единственная строка, от которой зависит вся схема, и обращение с ней показывает зрелость проекта. Источник — переменная окружения: os.Getenv("JWT_SECRET") при старте, и если пусто, падайте на запуске с внятной ошибкой: приложение, которое тихо подписывает токены пустым ключом, — дыра в готовом виде. Размер — не меньше 32 случайных байта, сгенерированные openssl rand -base64 32, а не «пароль компании»; в git — только .env.example. Ротация: добавить в header токенов поле kid (key ID) и держать несколько ключей — подписываете новым, проверяете по kid, старый выбрасываете, когда все exp истекли. Теперь алгоритмы. HS256 — симметричный: тот же секрет подписывает и проверяет, поэтому проверяющая сторона может и выдавать токены; для одного сервиса — идеально. RS256 и ES256 — асимметричные: auth-сервис подписывает приватным ключом, API проверяют публичным, который можно хоть на странице опубликовать. Проверяющий физически не может подделать токен — и это снимает целый класс вопросов, когда токен выпускает один сервис, а потребляют пять: identity-провайдер подписывает, все микросервисы проверяют публичным ключом, публикуемым через JWKS-эндпоинт. Для трекера на одном бинарнике HS256 достаточно, и усложнять не нужно; но когда появится второй сервис — переезд на RS256 заменит одну строку в keyfunc и одну в issueToken. Отдельный урок — хранение токена на клиенте, но и оно вытекает из анатомии: localStorage читает любой XSS (уроки про шаблоны и cookie это уже разбирали), HttpOnly cookie не читает никто, но её приложит CSRF-сценарий; рабочая схема для SPA — access в памяти JS, refresh в HttpOnly cookie, и эту связку доверши разбор CSRF в следующем уроке.

### Справочник: golang-jwt/v5 в gin

| Инструмент | Что делает | Когда брать |
| --- | --- | --- |
| jwt.NewWithClaims(method, claims) | собирает токен из claims и метода | выдача в /login |
| .SignedString([]byte(secret)) | подписывает, возвращает строку | выдача, сразу после NewWithClaims |
| jwt.RegisteredClaims | iss, sub, aud, exp, iat, nbf, jti | встраивать в свой тип claims |
| jwt.NewNumericDate(t) | время для exp/iat/nbf | заполнение RegisteredClaims |
| jwt.ParseWithClaims(str, &claims, keyfunc) | проверка подписи и времени | гвард и refresh |
| keyfunc: t.Method.(*jwt.SigningMethodHMAC) | фильтр алгоритма плюс ключ | защита от alg-подмены |
| errors.Is(err, jwt.ErrTokenExpired) | различение причин отказа | 401 TOKEN_EXPIRED против 401 |
| c.Set("claims", claims) | передача в хендлеры | гвард, после успешной проверки |
| c.MustGet("claims").(*UserClaims) | чтение в хендлере | фильтрация по user_id, RBAC |
| Authorization: Bearer <token> | транспорт токена | клиент, каждый запрос к API |

### JWT против сессий: что выбрать трекеру

Схемы не соперники, а инструменты, и выбор диктуют клиенты и требования. Хранение: сессия — на сервере, JWT — у клиента; значит, отзыв сессии — DEL в redis, отзыв JWT — только exp, denylist или token_version. Размер: id сессии — строка в 30-60 байт каждый запрос, JWT с claims — полкилобайта и больше на каждый вызов API — при тысячах запросов в секунду это заметные байты. Масштабирование: обе схемы горизонтальны, но сессии требуют общего хранилища, JWT — только общего секрета. Отладка: сессию видно в redis и в DevTools, JWT — только DevTools, и токен невозможно «поправить руками» — только перевыпустить. Клиенты: браузер любит cookie и сессии, мобильные приложения и бэкенд-потребители — Authorization-заголовок; cookie-транспорт для API-клиентов лишний, а JWT в localStorage для браузера — уязвимость. Практический вывод для трекера: держать обе схемы. Веб-интерфейс из урока про шаблоны живёт на сессиях из прошлого урока: SSR-страницы, cookie с HttpOnly, мгновенный logout через Clear. API /api/v1 — на JWT: мобильный клиент и будущие интеграции шлют Bearer без cookie, сервер не хранит ничего, auth-сервис можно вынести отдельно. Гибрид с одной точкой входа: /login может выдавать и то, и другое — сессию для браузера и пару токенов для API; гвард Auth() защищает только группу /api/v1, сессионное звено — только HTML-маршруты, и схемы не смешиваются в одном запросе. Когда появится микросервисная архитектура, JWT станет мостом между сервисами — и переезд на RS256 с JWKS будет напрашиваться сам.

### Отладка: частые ошибки Parse и как их читать

Поломанный JWT-флоу диагностируется по ответу 401, а его причины — по тексту ошибки, который гвард честно не показывает клиенту, но показывает вам в логе. Ошибка jwt.ErrTokenMalformed — строка не режется на три части: клиент прислал не токен, а JSON-ответ целиком (не выделил поле из {"access_token": "..."}), забыл префикс Bearer или прислал пустой заголовок; смотрите, что реально лежит в Authorization. ErrTokenSignatureInvalid — подпись не сошлась: сервер подписывает одним секретом, проверяет другим (два окружения, два .env), или токен склеили из частей двух разных токенов руками; сверяйте JWT_SECRET в обоих концах. ErrTokenExpired — exp прошёл: самый честный случай, refresh-эндпоинт для него и написан; если exp «не наступает», проверьте, что jwt.NewNumericDate берёт time.Now(), а не нулевое время. ErrTokenNotValidYet — nbf из будущего: чаще всего — часы клиента и сервера разошлись, а вы поставили nbf «на всякий случай». Отдельный кластер — 200 при ожидаемом 401: гвард не стоит на группе (api.Use(Auth()) забыт, звено повешено после регистрации маршрутов), или проверка только «есть ли заголовок» без ParseWithClaims. Верный end-to-end тест — curl: curl -s -X POST -d "..." localhost:8080/api/v1/login | jq -r .access_token — положите токен в переменную, curl -H "Authorization: Bearer $TOKEN" localhost:8080/api/v1/todos — и вы должны увидеть 200; повторите с усечённым токеном и получите 401 с UNAUTHORIZED. И помните про WWW-Authenticate: Bearer в ответе 401 — стандарт его просит, и клиенты по нему понимают, что нужно идти за токеном.

### POST /login и POST /token/refresh

```go
type LoginInput struct {
        Username string `json:"username" binding:"required,min=3"`
        Password string `json:"password" binding:"required,min=8"`
}

r.POST("/api/v1/login", func(c *gin.Context) {
        var input LoginInput
        if err := c.ShouldBindJSON(&input); err != nil {
                c.JSON(http.StatusBadRequest, gin.H{
                        "error":   "BAD_REQUEST",
                        "message": err.Error(),
                })
                return
        }
        user, ok := checkPassword(input.Username, input.Password) // bcrypt внутри
        if !ok {
                c.JSON(http.StatusUnauthorized, gin.H{
                        "error":   "UNAUTHORIZED",
                        "message": "логин или пароль неверны",
                })
                return
        }
        access, err := issueToken(user.ID, user.Role, 15*time.Minute)
        if err != nil {
                c.JSON(http.StatusInternalServerError, gin.H{"error": "INTERNAL"})
                return
        }
        refresh, err := issueToken(user.ID, user.Role, 24*time.Hour)
        if err != nil {
                c.JSON(http.StatusInternalServerError, gin.H{"error": "INTERNAL"})
                return
        }
        c.JSON(http.StatusOK, gin.H{
                "access_token":  access,
                "refresh_token": refresh,
                "token_type":    "Bearer",
                "expires_in":    900,
        })
})

r.POST("/api/v1/token/refresh", func(c *gin.Context) {
        var input struct {
                RefreshToken string `json:"refresh_token" binding:"required"`
        }
        if err := c.ShouldBindJSON(&input); err != nil {
                c.JSON(http.StatusBadRequest, gin.H{"error": "BAD_REQUEST"})
                return
        }
        claims := &UserClaims{}
        _, err := jwt.ParseWithClaims(input.RefreshToken, claims, verifyKey)
        if err != nil {
                c.JSON(http.StatusUnauthorized, gin.H{
                        "error":   "UNAUTHORIZED",
                        "message": "refresh недействителен",
                })
                return
        }
        access, err := issueToken(claims.UserID, claims.Role, 15*time.Minute)
        if err != nil {
                c.JSON(http.StatusInternalServerError, gin.H{"error": "INTERNAL"})
                return
        }
        c.JSON(http.StatusOK, gin.H{"access_token": access, "expires_in": 900})
})
```

LoginInput с тегами binding — валидация из урока про validator; checkPassword — ваша bcrypt-проверка, единственное место с plaintext. Пара access/refresh из одной issueToken с разными TTL. Refresh-эндпоинт проверяет токен той же verifyKey и выдаёт новый access; в проде добавьте сюда проверку, что это refresh, а не access, и ротацию с хранением jti.

### Проверка: keyfunc без проверки метода

**Вопрос:** keyfunc сервера сразу возвращает []byte(secret), не проверяя t.Method. На что это открывает дверь?

- Ни на что: подпись всё равно сверится по секрету
- ✅ На alg-подмену: токен с "alg":"none" или переклеенный HS256/RS256 может пройти проверку
- Только на истечение exp: время проверяется отдельно
- На XSS: без проверки метода токен читается из cookie

keyfunc — точка, где сервер решает, каким ключом сверять; без фильтра t.Method токен с чужим alg обрабатывается по своей логике. Историческая атака «alg»: «none» кормила библиотекам неподписанные токены, а HS256/RS256-confusion подписывает подделку публичным ключом. Одной строкой if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok вы закрываете оба класса. exp и подпись — разные проверки, но обе выполняются в ParseWithClaims.

### Соберите middleware-гвард

Допишите Auth(): прочитать Authorization, отбить отсутствие токена, проверить его и передать claims дальше по цепочке.

```go
func Auth() gin.HandlerFunc {
        return func(c *gin.Context) {
                header := c.GetHeader("Authorization")
                if !strings.HasPrefix(header, ___) {
                        c.AbortWithStatusJSON(http.___, gin.H{"error": "UNAUTHORIZED"})
                        return
                }
                tokenStr := strings.TrimPrefix(header, "Bearer ")
                claims := &UserClaims{}
                _, err := jwt.___(tokenStr, claims, verifyKey)
                if err != nil {
                        c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "UNAUTHORIZED"})
                        return
                }
                c.___("claims", claims)
                c.___()
        }
}
```

Подсказка: Схема Bearer с пробелом; без токена — 401 StatusUnauthorized; токен проверяет jwt.ParseWithClaims; claims кладёт c.Set, а передачу управления продолжит c.Next().

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

### Соберите путь JWT-запроса

От POST /api/v1/login до защищённого GET /api/v1/todos и обратно: разложите события по порядку.

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

1. `POST /api/v1/login: ShouldBindJSON валидирует LoginInput по тегам binding`
2. `checkPassword сверяет bcrypt-хэш — единственное место с plaintext пароля`
3. `issueToken собирает UserClaims: sub, iat, exp через 15 минут, jti`
4. `jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString(secret) — три части через точки`
5. `Клиент сохраняет пару токенов и шлёт Authorization: Bearer <access>`
6. `Запрос попадает в цепочку группы /api/v1: Logger, Auth(), хендлер`
7. `Auth отрезает префикс Bearer и зовёт jwt.ParseWithClaims с verifyKey`
8. `keyfunc проверяет alg и возвращает секрет — подпись и exp сходятся`
9. `c.Set("claims", claims), c.Next() — хендлер фильтрует задачи по claims.UserID`
10. `200 со списком задач; JSON-ответ уходит клиенту`
11. `Протухший exp на другом запросе — errors.Is(jwt.ErrTokenExpired), 401 TOKEN_EXPIRED`
12. `Клиент зовёт POST /api/v1/token/refresh и получает новый access без повторного логина`

### Карточки: аутентификация JWT

- **Три части JWT** — header.payload.signature, base64url через точки. Header — alg и typ; payload — claims (iss, sub, aud, exp, iat, nbf, jti и свои); signature — HMAC-SHA256(header.payload, secret) для HS256. Подпись не шифрование: payload читает любой, менять без ключа нельзя.
- **golang-jwt/jwt/v5: выдача** — Свой claims-тип с jwt.RegisteredClaims внутри; jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString([]byte(secret)). Время — jwt.NewNumericDate; exp/iat/nbf заполняются в RegisteredClaims и проверяются библиотекой автоматически.
- **ParseWithClaims и keyfunc** — jwt.ParseWithClaims(str, &claims, keyfunc) — режет строку, декодирует, зовёт keyfunc за ключом, сверяет подпись и время. В keyfunc — фильтр t.Method (защита от «alg»: «none» и HS256/RS256-confusion) и возврат []byte(JWT_SECRET). Ошибки различаются через errors.Is: ErrTokenExpired, ErrTokenNotValidYet, ErrTokenSignatureInvalid, ErrTokenMalformed.
- **Гвард и транспорт** — Authorization: Bearer <token>; гвард читает c.GetHeader, TrimPrefix, ParseWithClaims, при отказе AbortWithStatusJSON(401) — цепочка обрывается, при успехе c.Set("claims", claims) и c.Next(). 401 — не знаю, кто ты; 403 — знаю, но прав нет.
- **Access и refresh** — Access — 5-15 минут на каждый запрос, украденный быстро умирает. Refresh — сутки-недели, живёт в HttpOnly cookie или на сервере, единственная цель — получить новый access через POST /api/v1/token/refresh. Ротация refresh требует jti и хранилища; logout с JWT — короткий exp, denylist по jti или token_version в claims.
- **Секрет и алгоритмы** — JWT_SECRET из os.Getenv, не из git, минимум 32 случайных байта; пустой секрет — падать на старте. HS256 — один секрет на подпись и проверку, для одного сервиса. RS256/ES256 — приватный подписывает, публичный проверяет: identity-провайдер и микросервисы, ключи публикуются через JWKS. Ротация — kid в header.

### Семь правил JWT в gin

- Подпись не шифрование: base64url декодируется тривиально — в claims живут user_id, role и сроки, а не пароли и секреты.
- keyfunc без проверки t.Method — дыра: один if отсекает «alg»: «none» и HS256/RS256-confusion до сверки подписи.
- JWT_SECRET — из env, минимум 32 случайных байта, не в git; пустой секрет — паника на старте, а не тихая работа.
- Гвард — звено на группе /api/v1: AbortWithStatusJSON(401) без токена и на битом токене, c.Set("claims") и c.Next() на живом.
- 401 против 403: нет или протух токен — 401 с WWW-Authenticate: Bearer; токен жив, прав нет — 403.
- Access короткий (5-15 минут), refresh долгий и хранится строже: HttpOnly cookie или сервер с jti; logout — denylist или token_version, stateless не умеет забывать.
- Логин до токена: validator на LoginInput, bcrypt против хэша, единое «логин или пароль неверны», в логах jti и user_id — но не сам токен.

> **Прогоните JWT curl-ом.** Соберите сервер и пройдите флоу без браузера: curl -s -X POST -H "Content-Type: application/json" -d '{"username":"om","password":"secret1234"}' localhost:8080/api/v1/login | jq -r .access_token; положите токен в переменную TOKEN; curl -H "Authorization: Bearer $TOKEN" localhost:8080/api/v1/todos — 200; повторите без заголовка — 401 UNAUTHORIZED; усеките токен на середине — 401 UNAUTHORIZED снова; подождите 15 минут или перевыпустите с exp минутой назад — 401 TOKEN_EXPIRED, и refresh вернёт доступ. В браузерной песочнице сайта сетевой код не исполняется, поэтому runner-ов в курсе нет — интерактивы здесь quiz, predict, fill, order и флешкарты, а примеры живут в локальном go run, как и весь [курс gin](/courses/gin/). Дальше — 16-cors: CORS и безопасность — почему браузер блокирует ответы вашего API чужому фронту, звенья заголовков и обзор CSRF.

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

- JWT переносит данные в сам токен: header.payload.signature, base64url; подпись гарантирует целостность, но не конфиденциальность — payload читает любой.
- Выдача: свой claims-тип со встроенным jwt.RegisteredClaims, jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString([]byte(JWT_SECRET)); exp/iat/jti заполняются через jwt.NewNumericDate.
- Проверка: jwt.ParseWithClaims с keyfunc, где фильтруется t.Method и возвращается секрет; ошибки различаются errors.Is — ErrTokenExpired, ErrTokenSignatureInvalid, ErrTokenMalformed.
- Гвард Auth() на группе /api/v1: Authorization Bearer, без токена и на битом токене — AbortWithStatusJSON(401), на живом — c.Set("claims") и c.Next(); 401 против 403 — не знаю против нельзя.
- Refresh-схема: access 5-15 минут, refresh сутки-недели в HttpOnly cookie или на сервере, POST /api/v1/token/refresh; logout — denylist по jti или token_version, stateless не забывает сам.
- Секрет — os.Getenv("JWT_SECRET"), 32+ случайных байта, не в git; HS256 для одного сервиса, RS256 с JWKS — когда подписывает один сервис, а проверяют многие.
- Сессии и JWT не соперники: сессии — для браузерного SSR из урока про шаблоны, JWT — для API и мобильных клиентов; гибрид выдаёт /login, гварды не пересекаются.

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

Закройте /api/v1 трекера JWT: POST /api/v1/login с LoginInput (binding:min) и bcrypt-проверкой, issueToken на golang-jwt/v5 с exp 15 минут и jti, гвард Auth() с verifyKey и фильтром t.Method на группе, 401 TOKEN_EXPIRED с подсказкой про refresh, 403 для роли, отличной от admin, на маршруте /api/v1/admin. Добавьте POST /api/v1/token/refresh и ротацию access за протухший. Прогоните curl-ом пять сценариев: живой токен — 200, без заголовка — 401, битая подпись — 401, протухший — 401 TOKEN_EXPIRED, чужая роль на admin — 403. Вынесите JWT_SECRET в переменную окружения и убедитесь, что пустой секрет роняет приложение на старте. Запишите в два предложения, что сломается при выносе auth в отдельный сервис, и почему тогда понадобится RS256.

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

### Почему нельзя хранить JWT в localStorage?

localStorage читает любой выполняющийся на странице JavaScript: просочившийся XSS унесёт и access, и refresh. Для SPA рабочая схема — access только в памяти, refresh в HttpOnly cookie; для мобильных клиентов есть системные keychain/keystore.

### Как разлогинить пользователя с JWT?

Stateless-токен сервер «забыть» не может. Рабочие варианты: короткий exp как главный лимит, denylist по jti до exp (нужно хранилище), token_version в claims, которую вы инкрементируете при смене пароля или logout — все старые токены перестают проходить.

### Чем refresh отличается от второго access?

Назначением и правами. Access шлют на каждый запрос API, он короткий и может гулять по логам. Refresh нужен только на /token/refresh, живёт долго, хранится строже (HttpOnly cookie, сервер) и в идеале одноразовый — ротация с jti в redis.

### Почему 401, а не 403, когда протух токен?

401 — «не знаю, кто ты»: токена нет или он невалиден, и стандарт просит WWW-Authenticate: Bearer, чтобы клиент пошёл за новым. 403 — «знаю, но нельзя»: аутентификация прошла, не хватает прав; пере-логин не поможет, нужна другая роль.
