---
title: "JWT в микросервисах: gateway и identity"
description: "JWT в микросервисах: api gateway авторизация на входе, identity выпускает токены, проброс X-User-ID и X-User-Role и безопасность закрытой сети. С кодом."
course: "JWT-аутентификация в Go: golang-jwt и bcrypt"
url: "https://golangmaster.ru/courses/jwt-go/lessons/22-jwt-mikroservisy/"
published: "2026-09-19"
updated: "2026-10-02"
minutes: 18
---

# JWT в микросервисах: gateway и identity

*Курс «JWT-аутентификация в Go: golang-jwt и bcrypt», урок 22 из 25. ~18 минут. Обновлено 02.10.2026. Источник: https://golangmaster.ru/courses/jwt-go/lessons/22-jwt-mikroservisy/*

JWT микросервисы меняют место проверки: в монолите middleware смотрел токен в соседнем файле, а теперь между клиентом и биллингом три сервиса и внутренняя сеть, и проверка на каждом узле превращается в дублирование. Рабочая схема, на которой сходится большинство систем: identity-сервис выпускает токены, gateway проверяет их на входе и передаёт личность дальше заголовками, внутренние сервисы доверяют сети. Разбираем схему кодом — и границу, за которой доверие кончается.

Ситуация: сервис заказов вырос, и из него вытащили биллинг и уведомления. Первая наивная реакция — скопировать middleware с проверкой токена в каждый сервис и разложить секрет HS256 по репозиториям. Через полгода у вас три секрета, три версии проверки, отзыв токена работает в одном сервисе и не работает в двух других, и никто не может сказать, кто ответственный за валидацию. Схема, которая живёт дольше, разделяет роли: identity — единственный сервис, знающий пароли и выпускающий токены; gateway — единственная точка входа, проверяющая их; внутренние сервисы получают уже проверенную личность заголовками X-User-ID и X-User-Role и не разбирают токены вовсе. Такой расклад не единственный, но он простой, дешёвый и честный о своих границах — все места, где он требует сетевой изоляции, размечены ниже. Если вы строите систему на chi, порядок middleware из [урока про порядок chi](/courses/chi/lessons/18-middleware-poryadok/) пригодится на каждом узле.

### api gateway авторизация: что проверяем на входе

Gateway — первый сервер вашей инфраструктуры, который видит внешний запрос, и именно он делает api gateway авторизацию полной: разбор заголовка Authorization, ParseWithClaims с WithValidMethods, проверка exp, затем denylist по jti из [пятнадцатого урока](/courses/jwt-go/lessons/15-otzyv-tokenov/) — потому что отзыв обязан работать на периметре, а не в трёх сервисах одновременно. Рядом с проверкой токена на gateway естественно встаёт ограничение частоты: перебор логинов и всплески 401 отсекаются там же, до того как долетят до identity. После успешной проверки gateway не пересылает токен внутрь, а перекладывает claims в внутренние заголовки: X-User-ID, X-User-Role, X-Session-JTI. Почему не пересылает токен: внутренним сервисам не нужна криптография, а утечка заголовков во внутренней сети дешевле утечки годных токенов. Ещё одна обязанность, о которой забывают: вырезать одноимённые заголовки, пришедшие снаружи. Если клиент может прислать X-User-Role: admin на gateway, а gateway свой заголовок поверх не поставит, схема сломана ещё до проверки токена. Дорогая операция — проверка подписи — выполняется один раз на запрос, и это главный выигрыш: правки валидации живут в одном месте, логи отказов не размазаны по сервисам.

### Gateway: проверка токена и проброс личности

```go
func (g *Gateway) Auth(next http.Handler) http.Handler {
        return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
                token := bearerFrom(r) // из Authorization: Bearer ...
                claims := AppClaims{}
                _, err := jwt.ParseWithClaims(token, &claims,
                        func(t *jwt.Token) (any, error) { return g.secret, nil },
                        jwt.WithValidMethods([]string{"HS256"}))
                if err != nil || g.Revoked(claims.ID) {
                        http.Error(w, "токен недействителен", http.StatusUnauthorized)
                        return
                }
                r.Header.Del("X-User-ID")
                r.Header.Del("X-User-Role")
                r.Header.Del("X-Session-JTI")
                r.Header.Set("X-User-ID", claims.Subject)
                r.Header.Set("X-User-Role", claims.Role)
                r.Header.Set("X-Session-JTI", claims.ID)
                next.ServeHTTP(w, r)
        })
}
```

Проверка идёт в порядке из урока про разбор токена: подпись и срок в ParseWithClaims, затем denylist по jti. Del перед Set — та самая гигиена периметра: сначала вычищаем пришедшие снаружи заголовки личности, только потом ставим свои. Токен внутрь не уходит — сервисы получают три заголовка и не имеют ни секрета, ни ключа проверки.

### Identity: выпуск токенов и разделение ключей

Identity — второй участник схемы и единственный сервис, который видит пароли. Он хранит bcrypt-хеши из [девятого урока](/courses/jwt-go/lessons/09-bcrypt-paroli/), держит /login, /refresh и /logout, выпускает пару access/refresh по правилам [восьмого](/courses/jwt-go/lessons/08-refresh-token/) и вносит jti в denylist. Остальные сервисы про пароли не знают ничего — это избавляет биллинг от необходимости зависеть от базы пользователей и сужает место, где может утечь учётка. Здесь же выбирается алгоритм подписи, и выбор влияет на всю систему. HS256 с общим секретом требует, чтобы секрет был у эмитента и у проверяющего — в схеме с gateway это ровно два сервиса, что терпимо. RS256 или ECDSA из [двенадцатого урока](/courses/jwt-go/lessons/12-rsa-i-es/) разделяют ключи сильнее: приватный живёт только в identity, публичный раздаётся через JWKS из [тринадцатого](/courses/jwt-go/lessons/13-jwks/) любому, кому нужно проверять, — и тогда проверять токены сможет и сервис вне gateway-схемы, не получая права выпускать токены.

### Identity: единственный выпуск токенов

```go
func (i *Identity) handleLogin(w http.ResponseWriter, r *http.Request) {
        var in Credentials
        if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
                http.Error(w, "битый JSON", http.StatusBadRequest)
                return
        }
        hash, err := i.users.HashByEmail(in.Email)
        if err != nil || bcrypt.CompareHashAndPassword(hash, []byte(in.Password)) != nil {
                http.Error(w, "неверные данные входа", http.StatusUnauthorized)
                return
        }
        access, _ := i.IssueAccess(in.Email, i.users.RoleOf(in.Email))
        refresh, _ := i.IssueRefresh(in.Email)
        http.SetCookie(w, refreshCookie(refresh))
        writeJSON(w, map[string]string{"access": access})
}
```

Тот же код, что собирался в уроках про жизненный цикл, только теперь он живёт в отдельном сервисе и больше нигде в системе пароли не хранятся. Сообщения об ошибках не раскрывают, существует ли email. Обратная сторона выделения identity в отдельный узел — он становится самой ценной целью: его доступы, его логи и его сетевые права охраняются строже остальных сервисов.

### Сетевая изоляция: на чём держится доверие

Третий участник — внутренние сервисы: биллинг, уведомления, каталог. Их middleware не разбирает токены: он читает X-User-ID и X-User-Role из заголовков, кладёт в контекст и отдаёт RequireRole. Это работает при одном жёстком условии — сетевой изоляции. Внутренние порты не должны быть доступны извне: VPC и security groups в облаке, NetworkPolicy в Kubernetes, приватная подсеть на виртуалках. Единственный вход в периметр — gateway, и всё, что мимо него, не доезжает физически. Тогда заголовок — не механизм безопасности, а контракт внутри доверенной сети, и его подделка требует уже доступа к сети, а это другая категория угроз. Полезно проговорить это как правило: доверяете вы не заголовку, а изоляции сети, и заголовок лишь переносит решение gateway внутрь. Изоляция делится на вход и выход: вход — закрытые порты и один ingress, выход — запрет сервисам ходить куда попало, иначе взломанный каталог вытянет данные отовсюду. Как только появляется второй вход в периметр — тестовый ingress, забытый порт отладчика, админский SSH-туннель — правило перестаёт выполняться, и на этом месте схема ломается чаще всего.

> **Живое место: порт, который торчал наружу.** Классика жанра: один из внутренних сервисов имел второй ingress для отладки или Grafana-агента, и через него любые заголовки доходили без проверки. curl -H "X-User-Role: admin" на прямой адрес сервиса — и админский маршрут открыт без всякого токена. Такие вещи не ловятся тестами фронтенда: слой ниже всего приложения. Минимум защиты: NetworkPolicy или security group на внутренние порты, алерт на прямые запросы к сервисам извне периметра, и правило «заголовки X-User-* валиден только на приватной сети» в код-стайл-гайдах. Проверяется просто: с внешней машины попытка дотянуться до внутреннего порта обязана обрываться по таймауту.

### Кто за что отвечает в схеме

| Компонент | Обязанности | Главный риск |
| --- | --- | --- |
| Gateway | проверка токена, denylist, вырезание и проброс заголовков, rate limit | единая точка входа: деградация валит всю систему |
| Identity | bcrypt, выпуск пары, ротация refresh, отзыв по jti | самая ценная цель: утечка ключа или базы хешей |
| Внутренние сервисы | чтение X-User-ID/X-User-Role, RequireRole на маршрутах | доступный извне порт делает заголовки подделкой |
| Сеть | изоляция: один ingress, закрытые порты, политика трафика | тестовые и отладочные входы, забытые наушниками деплоя |

### jwt микросервисы: заголовки против повторной проверки

Схема gateway-only — не единственная, и выбор стоит делать осознанно. Альтернатива — повторная проверка: каждый сервис сам разбирает токен публичным ключом identity через JWKS, доверяя только подписи, а не сети. Плюсы честные: компрометация одного узла не даёт права выдавать себя за других, revocation-проверки можно держать локальными кешами, схема переживает появление второго входа в сеть. Минусы тоже: каждому сервису нужен доступ к JWKS и кеш ключей, отзыв access-токена между проверками кеша живёт дольше, логи отказов размазаны. Моё рабочее правило: до пяти-семи сервисов и закрытой сети gateway-only дешевле и достаточно; дальше — или повторная проверка с JWKS, или service mesh с mTLS, где личность канала проверяет инфраструктура. jwt микросервисы — это градиент доверия, и ступень выбирается по цене ошибки: для биллинга и мультиарендных систем я не оставляю одну только изоляцию сети, для внутренних инструментов — вполне.

### Внутренний сервис: RequireRole по заголовку

```go
func UserFromHeaders(r *http.Request) (User, bool) {
        id := r.Header.Get("X-User-ID")
        if id == "" {
                return User{}, false // без gateway сервис запросов не обслуживает
        }
        return User{ID: id, Role: r.Header.Get("X-User-Role")}, true
}

func RequireRole(role string, next http.Handler) http.Handler {
        return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
                u, ok := UserFromHeaders(r)
                if !ok {
                        http.Error(w, "запрос вне периметра", http.StatusUnauthorized)
                        return
                }
                if u.Role != role {
                        http.Error(w, "недостаточно прав", http.StatusForbidden)
                        return
                }
                next.ServeHTTP(w, r.WithContext(WithUser(r.Context(), u)))
        })
}
```

Пустой X-User-ID трактуется как запрос мимо gateway: внутренний сервис не обслуживает ничего напрямую, и это правило ловит как обращение снаружи, так и забытый конфиг балансировщика. 401 против 403 — как в тестах auth: не опознали против опознали и отказали. Токенов, ключей и секретов у сервиса нет — исчезло и то, что можно было бы в нём украсть.

> **Как повторить у себя.** Понадобится локальный Go: два процесса — gateway на 8080 и биллинг на 8081. Сначала уберите Del и покажите себе дыру: curl -H "X-User-ID: u1" -H "X-User-Role: admin" напрямую на 8081 проходит. Потом верните Del в gateway, а биллингу запретите обслуживать запросы без заголовков — и та же команда получит 401. Браузерная песочница курса HTTP не умеет, только fmt, strings, strconv, math, sort, errors и time, поэтому стенд из двух портов честно собирается локально, на весь вечер.

### Проверка: где проверяется подпись

**Вопрос:** В схеме gateway плюс внутренние сервисы кто проверяет подпись JWT?

- Каждый внутренний сервис независимо
- ✅ Только gateway на входе в периметр
- Только identity при выпуске токена
- Никто: подпись проверяет клиент

Криптография выполняется один раз — на периметре, чтобы сервисы не дублировали её и не хранили ключи. Внутри доверенной сети личность передаётся заголовками. Повторная проверка — отдельная, более дорогая схема из этого же урока, и её выбирают осознанно, а не по умолчанию.

### Проверка: на чём держится доверие

**Вопрос:** Почему заголовки X-User-Role безопасны для внутренних сервисов?

- Заголовок подписан HMAC и не подделывается
- Gateway шифрует значение заголовка
- ✅ Внутренние порты недоступны извне: подделка требует доступа к сети
- HTTP-заголовки нельзя установить в curl

Заголовок — обычный текст, его защищает не криптография, а изоляция: один ingress, закрытые порты, политика трафика. Как только у периметра появляется второй вход, правило ломается — и именно так выглядят самые громкие истории с внутренними заголовками в реальных системах.

### Запрос мимо gateway

```go
// Сеть: gateway 8080 (публичный), биллинг 8081 (приватный, NetworkPolicy).
// С внешней машины:
//   curl https://api.example.com/billing/charges -H "X-User-Role: admin" -> ?
//   curl http://10.0.2.15:8081/charges -H "X-User-Role: admin" -> ?
```

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

- Оба вернут 200: заголовки сильнее токенов
- ✅ Первый — 401 от gateway, второй — таймаут: порт закрыт политикой
- Оба вернут 401, потому что нет токена
- Первый — 500, второй — 200: биллинг доверяет заголовкам

Через gateway запрос без валидного токена отсекается до передачи внутрь, а прямой адрес приватной сети снаружи не отвечает вовсе — это и есть сетевая изоляция. Оба случая стоит проверять мониторингом, а не надеждой: алерт на чужие пакеты к 8081 дешёвый и ловит деградацию периметра раньше эксплуатации.

### Соберите проверку на gateway

Допишите разбор токена, белый список методов и проброс личности.

```go
_, err := jwt.ParseWithClaims(token, &claims,
        func(t *jwt.Token) (any, error) { return g.secret, nil },
        jwt.With___Methods([]string{"HS256"}))
if err != nil || g.Revoked(claims.___) {
        http.Error(w, "токен недействителен", http.StatusUnauthorized)
        return
}
r.Header.___("X-User-Role")
r.Header.Set("X-User-Role", claims.Role)
```

Подсказка: Белый список — WithValidMethods, отзыв смотрят по jti из claims.ID, а перед установкой своего заголовка чужой удаляют вызовом Del.

Правильные ответы в пропусках: `Valid`, `ID`, `Del`

### Соберите путь запроса через систему

Расставьте шаги запроса к биллингу в правильном порядке: от клиента до ответа.

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

1. `клиент: GET /billing/charges с Authorization: Bearer <access>`
2. `gateway: ParseWithClaims + WithValidMethods + denylist по jti`
3. `gateway: Del X-User-* и Set X-User-ID, X-User-Role`
4. `проксирование запроса в приватную сеть на биллинг`
5. `биллинг: UserFromHeaders и RequireRole`
6. `биллинг: ответ 200 со списком начислений`

### Карточки: jwt в микросервисах

- **Gateway** — Единственная точка проверки токена на периметре: подпись, exp, denylist. После проверки перекладывает claims в X-User-* и вырезает чужие одноимённые заголовки.
- **Identity** — Сервис, который знает пароли и выпускает токены: bcrypt, /login, /refresh, отзыв. Единственный владелец приватного ключа при RS256.
- **X-User-Role** — Заголовок с ролью внутри доверенной сети. Безопасен только при закрытых портах: подделка требует доступа к сети, а не к API.
- **Повторная проверка** — Каждый сервис сам проверяет токен публичным ключом через JWKS. Дороже, но переживает второй вход в периметр и компрометацию узла.
- **Сетевая изоляция** — VPC, NetworkPolicy, закрытые порты: единственный вход — gateway. Доверие заголовкам держится на ней, а не на самих заголовках.

Схема собрана: identity выпускает, gateway проверяет, сервисы доверяют изоляции. Дальше по курсу: почему payload токена нельзя править даже ради шутки — [двадцать третий урок](/courses/jwt-go/lessons/23-poddelka-podpisi/); чек-лист безопасности перед релизом — [двадцать четвёртый](/courses/jwt-go/lessons/24-luchshie-praktiki/); сборка всего в один сервис — [итоговый проект](/courses/jwt-go/lessons/25-itogovyj-proekt/). Из смежного: сервисные токены для связей вне пользовательских сессий разобраны в [семнадцатом уроке](/courses/jwt-go/lessons/17-service-tokeny/), хранение сессий вместо токенов — [урок про сессии Redis](/courses/go-redis/lessons/21-sessii-web/), а ограничение частоты на gateway — [rate limiter на Redis](/courses/go-redis/lessons/22-rate-limiter/). Карта всех тем — [на странице jwt-go](/courses/jwt-go/).

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

- Разделение ролей: identity выпускает токены, gateway проверяет на периметре, внутренние сервисы доверяют сети.
- Gateway вырезает пришедшие снаружи X-User-* перед установкой своих — иначе периметр не работает.
- Заголовки X-User-ID и X-User-Role безопасны только при сетевой изоляции: доверяешь не заголовку, а закрытым портам.
- HS256 с общим секретом терпим на два узла; для широкой проверки берут RS256 и JWKS.
- Повторная проверка в каждом сервисе — ступень выше: дороже, но переживает второй вход в периметр.
- Главный риск схемы — не криптография, а забытый порт или второй ingress.

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

Соберите стенд из двух процессов: gateway с middleware Auth и биллинг с RequireRole. Сначала не ставьте Del заголовков и покажите, что curl с X-User-Role: admin проходит мимо токена; затем добавьте вырезание и требование заголовков в биллинге и добейтесь 401 на прямые запросы. Выпишите список портов своего проекта и проверьте, какие из них доступны снаружи: для каждого доступного порта определите, кто его обязан видеть. В конце добавьте алерт на запросы к внутренним сервисам из внешней сети.

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

### Нужен ли отдельный сервис identity, если пользователей пока сто?

Нет: на старте identity — это модуль монолита с чёткими границами пакетов. Выделять его в процесс стоит, когда появляется второй сервис, которому нужен логин, — тогда границы пакетов превращаются в границы сети, и переезд дешевле, если границы уже были нарисованы.

### Почему бы не передавать сам токен внутренним сервисам?

Можно, и это основа схемы повторной проверки. Но в gateway-only варианте токен внутри не нужен: сервисы не проверяют подпись, а токен в логах прокси внутренней сети превращается в лишнюю копию годного access. Заголовки с личностью дешевле и в утечку дают меньше.

### Как быть с отзывом access-токена, если сервисы токен не проверяют?

Отзыв обязан стоять на gateway — там он и живёт, в denylist по jti. Пока запрос внутри периметра, это вопрос миллисекунд; если нужен отзыв без gateway, переходите к повторной проверке или короткому exp — компромиссы разобраны в уроке про отзыв токенов.
