← назад к разделу

Когда браузер или мобильное приложение обращается к 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() и логи.