Когда сервис А вызывает сервис Б внутри кластера, кажется, что всё безопасно — internal network, VPC, «свои». Но скомпрометированный контейнер, сетевой перехват или атака изнутри — реальные сценарии. Именно поэтому межсервисный трафик аутентифицируется так же строго, как пользовательский.
В Go есть два устоявшихся подхода: mTLS и Client Credentials Flow. Разберём оба.
Зачем аутентифицировать трафик между своими сервисами
Представьте: сервис заказов вызывает сервис товаров по HTTP без заголовка Authorization. Если кто-то получит доступ к подам кластера, он сможет дёргать любой сервис, притворяясь любым другим. Анонимный межсервисный вызов — это не «экономия на аутентификации», а дыра в периметре.
Правило простое: каждый запрос между сервисами несёт удостоверение — либо в транспорте (mTLS), либо в заголовке (Authorization: Bearer).
Способ 1: mTLS
mTLS (mutual TLS) — двусторонний TLS, где обе стороны предъявляют сертификат. Клиент доказывает серверу «я сервис заказов», сервер доказывает клиенту «я сервис товаров». Никаких паролей, никаких токенов — identity встроена в транспортный слой.
В Kubernetes-кластере с Istio или Linkerd всё происходит автоматически: каждому поду выдаётся SPIFFE-сертификат, sidecar-прокси шифрует трафик и проверяет сертификаты. Приложение на Go вообще не пишет auth-код — всё делает sidecar.
Если Service Mesh недоступен (локальная разработка, тест-стенд без Istio), mTLS можно настроить напрямую через стандартную библиотеку:
// adapters/out/product/client.go
func NewMTLSClient(certFile, keyFile, caFile string) (*http.Client, error) {
cert, err := tls.LoadX509KeyPair(certFile, keyFile)
if err != nil {
return nil, fmt.Errorf("load client cert: %w", err)
}
caCert, err := os.ReadFile(caFile)
if err != nil {
return nil, fmt.Errorf("read ca cert: %w", err)
}
pool := x509.NewCertPool()
pool.AppendCertsFromPEM(caCert)
tlsCfg := &tls.Config{
Certificates: []tls.Certificate{cert},
RootCAs: pool,
MinVersion: tls.VersionTLS12,
}
return &http.Client{
Transport: &http.Transport{TLSClientConfig: tlsCfg},
Timeout: 10 * time.Second,
}, nil
}
Пути до файлов сертификатов берутся из переменных окружения — их монтирует Kubernetes из Secret. Никаких путей прямо в коде:
// cmd/app/config.go
type Config struct {
MTLS struct {
CertFile string `envconfig:"MTLS_CERT_FILE,required"`
KeyFile string `envconfig:"MTLS_KEY_FILE,required"`
CAFile string `envconfig:"MTLS_CA_FILE,required"`
}
ProductSvcURL string `envconfig:"PRODUCT_SVC_URL,required"`
}
Главное преимущество mTLS: identity нельзя «забыть» добавить в запрос — она в транспорте. Ротация сертификатов при Istio автоматическая, раз в 24 часа.
Способ 2: Client Credentials Flow
Когда Service Mesh недоступен или не планируется, используют Client Credentials Flow — стандартный OAuth2-механизм для машинного взаимодействия.
Схема простая:
- Сервис заказов запрашивает токен у IdP (Keycloak, Auth0): «я order-service, вот мой
client_idиclient_secret, хочу токен со scopeservice:product:read». - IdP возвращает access_token.
- Сервис заказов ставит токен в заголовок
Authorization: Bearer ...и вызывает сервис товаров. - Сервис товаров проверяет JWT: подпись, срок действия, scope.
В Go это реализует пакет golang.org/x/oauth2/clientcredentials. Ключевая особенность: TokenSource кеширует токен и автоматически обновляет его до истечения. Хранить токен вручную или обновлять самостоятельно не нужно — пакет это делает.
// cmd/app/wire.go
import "golang.org/x/oauth2/clientcredentials"
ccCfg := clientcredentials.Config{
ClientID: cfg.S2S.ClientID,
ClientSecret: cfg.S2S.ClientSecret,
TokenURL: cfg.S2S.TokenURL,
Scopes: []string{"service:product:read"},
}
tokenSrc := oauth2.ReuseTokenSource(nil, ccCfg.TokenSource(ctx))
productClient := product.NewClient(tokenSrc, cfg.ProductSvcURL)
oauth2.ReuseTokenSource добавляет поверх базового TokenSource кеш: повторный вызов .Token() отдаёт уже полученный токен вплоть до его истечения. Без ReuseTokenSource каждый запрос пойдёт за новым токеном в IdP.
Клиент сервиса, использующий токен:
// adapters/out/product/client.go
type Client struct {
http *http.Client
tokenSrc oauth2.TokenSource
baseURL string
}
func NewClient(tokenSrc oauth2.TokenSource, baseURL string) *Client {
return &Client{
http: &http.Client{Timeout: 10 * time.Second},
tokenSrc: tokenSrc,
baseURL: baseURL,
}
}
func (c *Client) GetProduct(ctx context.Context, productID string) (Product, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet,
c.baseURL+"/products/"+productID, nil)
if err != nil {
return Product{}, fmt.Errorf("build request: %w", err)
}
tok, err := c.tokenSrc.Token()
if err != nil {
return Product{}, fmt.Errorf("get token: %w", err)
}
req.Header.Set("Authorization", "Bearer "+tok.AccessToken)
resp, err := c.http.Do(req)
// ...
}
Обратите внимание: токен ставится в клиенте (adapters/out/), а не в обработчике входящих запросов. Это важно — auth-логика входящего и исходящего трафика не смешивается.
Scope задаётся по операции
Ошибка — выдать одному сервису единый широкий scope service на всё. Правильно — отдельный scope на каждую операцию:
// adapters/out/inventory/client.go
ccInventory := clientcredentials.Config{
ClientID: cfg.S2S.ClientID,
ClientSecret: cfg.S2S.ClientSecret,
TokenURL: cfg.S2S.TokenURL,
Scopes: []string{"service:inventory:reserve"}, // не "service"
}
// adapters/out/customer/client.go
ccCustomer := clientcredentials.Config{
Scopes: []string{"service:customer:read"},
}
Разный scope — разные права. Если токен сервиса заказов утечёт, злоумышленник сможет только резервировать инвентарь, но не читать данные клиентов и не делать что-то ещё. Радиус поражения ограничен.
Конфигурация: секреты только из окружения
client_secret нельзя хардкодить в исходниках или конфигурационных файлах в репозитории. Только из переменных окружения или Vault:
// cmd/app/config.go
type S2SConfig struct {
ClientID string `envconfig:"S2S_CLIENT_ID,required"`
ClientSecret string `envconfig:"S2S_CLIENT_SECRET,required"`
TokenURL string `envconfig:"S2S_TOKEN_URL,required"`
}
envconfig с тегом required упадёт при старте, если переменная не задана — лучше упасть на старте, чем незаметно работать без аутентификации. И никогда не логируйте структуру Config целиком — client_secret не должен попасть в логи.
Типичные ошибки
Анонимный вызов через http.DefaultClient. Самая частая ошибка — использовать http.Get(...) или создать http.Client{} без TokenSource или tls.Config. Выглядит безобидно, на деле — запрос без удостоверения:
// Неправильно: анонимный вызов к другому сервису
func (c *CustomerClient) GetCustomer(ctx context.Context, id string) (Customer, error) {
resp, err := http.Get(c.baseURL + "/customers/" + id)
// ...
}
Правильно — TokenSource в клиенте, как в примере выше.
Ручное управление токеном. Некоторые разработчики хранят токен в переменной и сами проверяют expires_in. Это изобретение велосипеда — oauth2.ReuseTokenSource уже делает всё правильно.
Один scope на все вызовы. scope=service или scope=internal — слишком широко. Если скомпрометирован один клиент, под угрозой все операции.
Секрет в коде. client_secret: "abc123" прямо в config.go — секрет попадает в историю git навсегда.
Коротко
- Анонимный межсервисный трафик — дыра в безопасности; каждый запрос аутентифицируется.
- mTLS — identity в транспорте; при Istio/Linkerd приложение не пишет auth-код вообще.
- Client Credentials Flow — OAuth2 для машин; сервис получает токен у IdP и ставит его в
Authorization: Bearer. - В Go —
clientcredentials.Config+oauth2.ReuseTokenSource; токен кешируется и обновляется автоматически. - Scope — по операции (
service:product:read), не один широкий на все вызовы. client_secret— только из переменных окружения, никогда в коде или git.- Токен ставится в исходящем клиенте (
adapters/out/), не в обработчике входящих запросов.
Что почитать дальше
- JWT validation — как принимающий сервис проверяет токен.
- PII и секреты —
client_secretчерез Vault, PII не в логах. - RBAC: роли — роль
systemдля S2S-вызовов.