Содержание урока · 13 разделов
- gin jwt: stateless-альтернатива сессиям
- Анатомия токена: header, payload, signature
- Пароли и LoginInput: JWT начинается до токена
- Проверка: ParseWithClaims и keyfunc
- Refresh-токены: короткий access, долгий refresh
- Секрет и алгоритмы: HS256 против RS256
- Справочник: golang-jwt/v5 в gin
- JWT против сессий: что выбрать трекеру
- Отладка: частые ошибки Parse и как их читать
- Семь правил JWT в gin
- Песочница урока
- Коротко: что запомнить
- Вопросы и ответы
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
GOpackage 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"}. Что он может сделать с этой ролью?
Проверка: 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
GOvar 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. Какой статус правильный?
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 на сутки: утечка жёстко ограничена по времени, а инфраструктура отзыва не нужна.
Протухший токен в гварде
Прочитайте код и выберите, что он выведет:
// 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})
})Секрет и алгоритмы: 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
GOtype 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. На что это открывает дверь?
Соберите middleware-гвард
Допишите Auth(): прочитать Authorization, отбить отсутствие токена, проверить его и передать claims дальше по цепочке.
Соберите путь JWT-запроса
От POST /api/v1/login до защищённого GET /api/v1/todos и обратно: разложите события по порядку.
Строки
Ваша программа
Нажимайте на строки слева, чтобы собрать программу
Карточки: аутентификация JWT
Семь правил 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 — но не сам токен.
Соберите сервер и пройдите флоу без браузера: 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. Дальше — 16-cors: CORS и безопасность — почему браузер блокирует ответы вашего API чужому фронту, звенья заголовков и обзор CSRF.
Песочница урока
Редактируйте код и запускайте его прямо здесь — интерпретатор работает в браузере, ничего устанавливать не нужно. За каждый запуск — +2 XP.
Запустите код, чтобы увидеть вывод.
Коротко: что запомнить
- 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 — «знаю, но нельзя»: аутентификация прошла, не хватает прав; пере-логин не поможет, нужна другая роль.
Термины урока
Ключевые понятия из этого урока — короткие определения в глоссарии. Наведите на термин, чтобы вспомнить, откройте карточку для подробностей и ссылки на практику.
Сравните с альтернативами
Практический вопрос после первых уроков: а почему именно эта библиотека? Мы разобрали конкурентов по одним критериям, с кодом одной задачи рядом и персональным подбором под ваши приоритеты.
Изучайте дальше
Следующая ступень учебного пути
Echo: минималистичный быстрый фреймворк
Курс Echo для Go: маршруты, middleware, валидация, JWT, SSE, тестирование и деплой. 25 уроков с примерами и итоговым API-проектом.
- Строить REST API на Echo
- Использовать типизированные запросы/ответы (Request/Response)
- Управлять ошибками через echo.HTTPError
- Писать middleware, включая JWT-аутентификацию