Когда браузер или мобильное приложение обращается к API, они вкладывают в заголовок запроса JWT-токен. Задача сервера — убедиться, что токен настоящий: подписан доверенным Identity Provider, не просрочен, предназначен именно этому сервису. Если проверку написать вручную, легко пропустить один шаг — и атакующий пройдёт как авторизованный пользователь. Поэтому используют готовые библиотеки с проверенной реализацией.
В Go стандартная пара — github.com/golang-jwt/jwt/v5 для разбора токена и github.com/MicahParks/keyfunc/v3 для получения публичных ключей из Identity Provider.
Зачем нужен JWKS-кеш
JWT подписывается приватным ключом Identity Provider (Keycloak, Auth0 и другие). Чтобы проверить подпись, сервис должен знать публичный ключ. Проблема: ключи меняются (rotation) — и сервис должен получать новые автоматически.
Решение — JWK Set (JWKS): Identity Provider публикует набор публичных ключей по URL-адресу (jwks_uri). Библиотека keyfunc скачивает их при старте и держит в памяти. Когда приходит токен с неизвестным kid (идентификатором ключа), keyfunc автоматически обновляет кеш — rotation ключей не требует вашего кода.
Хранить публичные ключи вручную в конфигурации или файлах не нужно: это ненадёжно и ломается при смене ключа.
JWTValidator: как устроена проверка
Структура JWTValidator инициализируется один раз при старте приложения. Она хранит JWKS-кеш и параметры токена: откуда он выдан (issuer) и для кого предназначен (audience).
// adapters/in/http/security/jwt.go
package security
import (
"fmt"
"github.com/MicahParks/keyfunc/v3"
"github.com/golang-jwt/jwt/v5"
)
type Principal struct {
Sub string
Roles []string
}
type JWTValidator struct {
jwks *keyfunc.JWKS
issuer string
audience string
}
func NewJWTValidator(jwksURI, issuer, audience string) (*JWTValidator, error) {
jwks, err := keyfunc.NewDefault([]string{jwksURI})
if err != nil {
return nil, fmt.Errorf("jwks init: %w", err)
}
return &JWTValidator{jwks: jwks, issuer: issuer, audience: audience}, nil
}
func (v *JWTValidator) Validate(tokenStr string) (*Principal, error) {
token, err := jwt.Parse(tokenStr, v.jwks.Keyfunc,
jwt.WithIssuer(v.issuer),
jwt.WithAudience(v.audience),
jwt.WithValidMethods([]string{"RS256", "ES256"}),
jwt.WithExpirationRequired(),
)
if err != nil || !token.Valid {
return nil, &apperr.AuthError{Reason: "invalid token"}
}
claims, ok := token.Claims.(jwt.MapClaims)
if !ok {
return nil, &apperr.AuthError{Reason: "malformed claims"}
}
return &Principal{
Sub: claims["sub"].(string),
Roles: extractRoles(claims),
}, nil
}
jwt.Parse с этими опциями проверяет:
- Подпись — через публичный ключ из JWKS-кеша.
exp— токен не просрочен. БезWithExpirationRequired()библиотека пропускает просроченные токены молча, поэтому опция обязательна.iss— токен выдан именно вашим Identity Provider, а не чужим.aud— токен предназначен этому сервису, а не другому.alg— разрешены толькоRS256иES256; токен без подписи (alg: none) отклоняется.
Middleware для chi
Проверку токена не нужно повторять в каждом обработчике. Для этого в chi есть middleware — функция, которая стоит перед всеми обработчиками и запускается на каждый запрос.
// adapters/in/http/middleware/authn.go
package middleware
import (
"context"
"net/http"
"strings"
"github.com/example/orders/adapters/in/http/httperr"
"github.com/example/orders/adapters/in/http/security"
"github.com/example/orders/core/apperr"
)
type ctxKey string
const principalKey ctxKey = "principal"
func AuthN(v *security.JWTValidator) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
header := r.Header.Get("Authorization")
if !strings.HasPrefix(header, "Bearer ") {
httperr.Write(w, r, &apperr.AuthError{Reason: "missing bearer token"})
return
}
principal, err := v.Validate(strings.TrimPrefix(header, "Bearer "))
if err != nil {
httperr.Write(w, r, err)
return
}
ctx := context.WithValue(r.Context(), principalKey, principal)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
}
Middleware извлекает токен из заголовка Authorization: Bearer <token>, валидирует его и кладёт разобранный *Principal в context.Context. Обработчики достают его через PrincipalFrom(ctx).
В роутере middleware подключается один раз:
r := chi.NewRouter()
r.Use(middleware.AuthN(jwtValidator))
r.Group(func(r chi.Router) {
r.Use(middleware.RequireRoles("customer"))
r.Post("/orders", h.CreateOrder)
r.Get("/orders/{id}", h.GetOrder)
})
В контексте хранится только разобранный *Principal, не сам токен-строка. Если положить сырой токен, обработчики получают возможность разобрать его повторно — а значит, могут сделать это неправильно.
Конфигурация через переменные окружения
jwks_uri, issuer и audience приходят из переменных окружения — не из кода и не из репозитория.
// cmd/app/config.go
type Config struct {
JWKSUri string `envconfig:"JWKS_URI,required"`
Issuer string `envconfig:"JWT_ISSUER,required"`
Audience string `envconfig:"JWT_AUDIENCE,required"`
}
// cmd/app/wire.go
jwtValidator, err := security.NewJWTValidator(cfg.JWKSUri, cfg.Issuer, cfg.Audience)
if err != nil {
slog.Error("jwks init failed", "err", err)
os.Exit(1)
}
Если при старте JWKS недоступен — приложение не запускается. Это правильно: лучше упасть сразу, чем принимать неавторизованные запросы.
401 и 403 — в чём разница
Это не просто разные числа. Для клиентского приложения каждый код несёт конкретную инструкцию к действию.
401 Unauthorized означает «запрос не аутентифицирован»: токен отсутствует, подпись не верна или срок действия истёк. Клиент должен запустить refresh-flow или попросить пользователя войти заново.
403 Forbidden означает «пользователь известен, но у него нет прав». Токен валидный, но роль не подходит. Refresh здесь не поможет — нужно показать «доступ запрещён».
Если перепутать и отдать 403 на просроченный токен, клиент покажет «доступ запрещён» и не запустит refresh. Пользователь не сможет продолжить работу, пока не перелогинится вручную.
// adapters/in/http/httperr/render.go
func Write(w http.ResponseWriter, r *http.Request, err error) {
var authErr *apperr.AuthError
if errors.As(err, &authErr) {
w.WriteHeader(http.StatusUnauthorized) // 401
json.NewEncoder(w).Encode(problem{Status: 401, Detail: "unauthenticated"})
return
}
var forbErr *apperr.ForbiddenError
if errors.As(err, &forbErr) {
w.WriteHeader(http.StatusForbidden) // 403
json.NewEncoder(w).Encode(problem{Status: 403, Detail: "forbidden"})
return
}
// ...
}
Почему самописный разбор токена опасен
JWT выглядит просто: три base64-строки через точку. Кажется, его легко разобрать вручную. На практике ручная реализация почти всегда пропускает один из этих случаев:
- Не проверяется
iss— атакующий подсовывает токен от своего Identity Provider, сервис принимает. - Не проверяется
aud— токен, выданный для другого сервиса, принимается как свой. - Не отклоняется
alg: none— токен без подписи проходит как валидный. - Нет учёта clock skew — просроченный токен проходит из-за расхождения часов на несколько минут.
- JWKS не кешируется — каждый запрос обращается к Identity Provider, который становится единой точкой отказа.
- При смене ключа все валидные токены получают 401, пока кеш не обновится.
Библиотека golang-jwt/jwt/v5 с keyfunc/v3 закрывает все эти случаи. Переизобретать это своими руками означает воспроизводить ту же логику с неизбежными пропусками.
Коротко
- Для проверки JWT используют
golang-jwt/jwt/v5— самописный разбор подписи ненадёжен. - Публичные ключи берут из JWKS-эндпоинта Identity Provider через
keyfunc/v3; библиотека кеширует их и обновляет при смене ключа автоматически. jwt.Parseнужно вызывать сWithExpirationRequired()— иначе просроченные токены проходят молча.- Алгоритм ограничивают через
WithValidMethods([]string{"RS256", "ES256"}), чтобы отклонять токены без подписи. - Middleware
AuthNставится на роутер один раз; в контекст кладётся разобранный*Principal, не сырой токен. - 401 — токен невалиден, клиент запускает refresh. 403 — токен валиден, но прав нет. Перепутать значит сломать UX.
jwks_uri,issuer,audience— всегда из переменных окружения, не из кода.
Что почитать дальше
- RBAC: маппинг ролей —
extractRolesизrealm_access.rolesиscope,RequireRolesна chi-группах. - ABAC: владение ресурсом — проверка
order.CustomerID == principal.Subв обработчике. - Service-to-service — Client Credentials Flow и mTLS для внутренних вызовов.
- PII и секреты — что нельзя класть в
error.Error()и логи.