Когда браузер или мобильное приложение обращается к 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).
// core/security/principal.go
package security
type Principal struct {
Sub string
Roles []string
}
type principalKey struct{}
func WithPrincipal(ctx context.Context, p *Principal) context.Context {
return context.WithValue(ctx, principalKey{}, p)
}
func PrincipalFrom(ctx context.Context) *Principal {
p, _ := ctx.Value(principalKey{}).(*Principal)
return p
}
Principal лежит в ядре, потому что его читают обработчики сценариев, а ядро не импортирует адаптеры. Сам валидатор — адаптер входа:
// adapters/in/http/security/jwt.go
package security
import (
"fmt"
"github.com/MicahParks/keyfunc/v3"
"github.com/golang-jwt/jwt/v5"
coresec "github.com/example/orders/core/security"
)
type JWTValidator struct {
jwks keyfunc.Keyfunc
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) (*coresec.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(),
jwt.WithLeeway(30*time.Second),
)
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"}
}
sub, err := claims.GetSubject()
if err != nil || sub == "" {
return nil, &apperr.AuthError{Reason: "missing sub"}
}
return &coresec.Principal{
Sub: sub,
Roles: extractRoles(claims),
}, nil
}
jwt.Parse с этими опциями проверяет:
- Подпись — через публичный ключ из JWKS-кеша.
exp— токен не просрочен: просроченный библиотека отклонит и без опций.WithExpirationRequired()закрывает другую дыру — токен, в котором поляexpнет вовсе, иначе прошёл бы как бессрочный.WithLeewayпрощает расхождение часов между провайдером и сервисом на заданный запас.iss— токен выдан именно вашим Identity Provider, а не чужим.aud— токен предназначен этому сервису, а не другому.alg— разрешены толькоRS256иES256: токен без подписи (alg: none) отклоняется, как и подмена алгоритма, когда публичный ключ пытаются скормить как секрет HMAC (RS256→HS256).
Middleware для chi
Проверку токена не нужно повторять в каждом обработчике. Для этого в chi есть middleware — функция, которая стоит перед всеми обработчиками и запускается на каждый запрос.
// adapters/in/http/middleware/authn.go
package middleware
import (
"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"
coresec "github.com/example/orders/core/security"
)
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 := coresec.WithPrincipal(r.Context(), principal)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
}
Middleware извлекает токен из заголовка Authorization: Bearer <token>, валидирует его и кладёт разобранный *Principal в context.Context через coresec.WithPrincipal. Обработчики достают его через security.PrincipalFrom(ctx) из того же пакета core/security — один ключ контекста на запись и чтение, иначе ядро будет получать nil.
В роутере 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— токен без подписи проходит как валидный. - Нет запаса на расхождение часов — при разнице в минуту валидный токен отвергается как «ещё не действителен» или как просроченный; библиотека даёт
jwt.WithLeeway. - JWKS не кешируется — каждый запрос обращается к Identity Provider, который становится единой точкой отказа.
- При смене ключа все валидные токены получают 401, пока кеш не обновится.
Библиотека golang-jwt/jwt/v5 с keyfunc/v3 закрывает все эти случаи. Переизобретать это своими руками означает воспроизводить ту же логику с неизбежными пропусками.
Коротко
- Для проверки JWT используют
golang-jwt/jwt/v5— самописный разбор подписи ненадёжен. - Публичные ключи берут из JWKS-эндпоинта Identity Provider через
keyfunc/v3; библиотека кеширует их и обновляет при смене ключа автоматически. jwt.Parseнужно вызывать сWithExpirationRequired()— иначе токен без поляexpпроходит как бессрочный; просроченные библиотека отклоняет и так.- Алгоритм ограничивают через
WithValidMethods([]string{"RS256", "ES256"}), чтобы отклонять токены без подписи. - Middleware
AuthNставится на роутер один раз; в контекст кладётся разобранный*Principal, не сырой токен.PrincipalиWithPrincipal/PrincipalFromживут вcore/security. - 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()и логи.