---
title: "Сервисные токены: сервис-сервис"
description: "Сервисные токены go: api ключи против jwt для связи сервис-сервис, HMAC-подпись запроса с ключом из env, сервисный JWT с aud, ротация и хранение секретов."
course: "JWT-аутентификация в Go: golang-jwt и bcrypt"
url: "https://golangmaster.ru/courses/jwt-go/lessons/17-service-tokeny/"
published: "2026-09-19"
updated: "2026-10-02"
minutes: 16
---

# Сервисные токены: сервис-сервис

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

Сервисные токены в Go решают задачу, к которой пользовательский jwt не приспособлен: service-a вызывает service-b, и личности пользователя в запросе нет. Разбираем api ключи vs jwt для связи сервис-сервис: статичный ключ с HMAC-подписью запроса, короткоживущий JWT с аудиторией, ротацию и хранение секретов в env.

Первый внутренний вызов появляется невинно: заказ сохранился — нужно уведомить рассыльщик, отчёт собрался — обновить биллинг. И здесь же совершается самая частая ошибка темы: для межсервисного вызова берут пользовательский jwt из тестового скрипта или заводят в базе служебного пользователя с паролем. Оба варианта ломают модель: токен привязан к человеку и умрёт вместе с его сессией, а у служебной учётки есть пароль, который можно перебрать, забыть сменить и утечь вместе с дампом. Машине нужен собственный идентификатор: сервисные токены в Go — это либо статичный API-ключ с подписью запроса, либо короткоживущий jwt с аудиторией, в которой записан вызываемый сервис. Разберём оба варианта, сравним api ключи vs jwt по честным критериям и соберём рабочую пару клиент-проверяющий. Всё, что здесь строится, не требует ни внешних сервисов, ни consul с vault: два процесса и две переменные окружения — уже рабочий стенд.

### API-ключи против JWT между сервисами

Статичный API-ключ — длинная случайная строка, одна на пару сервисов: клиент держит её в переменной окружения и прикладывает к запросу, проверяющий сравнивает со своей копией — в идеале в постоянное время, чтобы не подсказывать перебор по времени ответа. Просто, отзывается правкой конфига, но каждый запрос несёт один и тот же секрет: кто перехватил, владеет навсегда, пока вы не сменили. JWT-вариант переворачивает свойство: секрет по сети не ходит, вместо него летит подписанный токен на пять минут с полями sub — кто зовёт, aud — кого зовут — и сроком. Утёкший токен мёртв сам собой, зато появляется инфраструктура: выпуск, проверка, часы, распределение ключей. Моя граница выбора проста: пара сервисов с внутренним трафиком живёт с HMAC-подписью запроса; как только вызываемых становится больше двух-трёх или в вызов нужно положить контекст пользователя — берём короткоживущий jwt и дальше асимметрию из [урока про RS256](/courses/jwt-go/lessons/12-rsa-i-es/). Отдельный фактор — аудит: у статичного ключа все вызовы выглядят одинаково, а jwt с iss и jti оставляет в логах, кто, когда и каким вызовом пришёл — при разборе инцидентов это половина картины.

### API-ключ в env и HMAC-подпись вызова

```go
func signedRequest(method, url string, body []byte) (*http.Request, error) {
        key := []byte(os.Getenv("BILLING_KEY"))
        ts := strconv.FormatInt(time.Now().Unix(), 10)
        mac := hmac.New(sha256.New, key)
        mac.Write([]byte(method + " " + url + " " + ts + " "))
        mac.Write(body)

        req, err := http.NewRequest(method, url, bytes.NewReader(body))
        if err != nil {
                return nil, err
        }
        req.Header.Set("Content-Type", "application/json")
        req.Header.Set("X-Service-ID", "notifier")
        req.Header.Set("X-Timestamp", ts)
        req.Header.Set("X-Signature", hex.EncodeToString(mac.Sum(nil)))
        return req, nil
}
```

Ключ не уезжает по сети: в заголовках идут имя вызывающего, метка времени и HMAC-SHA256 от метода, адреса, времени и тела. Секрет читается из переменной окружения — та же дисциплина, что и для HMAC-секрета из [пятого урока](/courses/jwt-go/lessons/05-hmac-podpis/). Подпись покрывает не только payload: метод и путь не дают переподставить запрос на другой эндпоинт, а время отсекает повтор через час. Подмена любого поля — тела, пути, метки — делает подпись недействительную. Тело подписывается как есть — байты, которые уйдут по сети: сначала подписал, потом изменил payload — и проверка развалится, что и требуется.

Проверяющая сторона симметрична и коротка. Сервис достаёт из заголовков X-Service-ID, X-Timestamp и X-Signature, берёт ключ этого сервиса из своего env — у каждой пары свой — и пересчитывает HMAC от тех же полей. Сравнивать подписи надо в постоянное время: crypto/subtle.ConstantTimeCompare не выходит раньше при первом несовпадающем байте, и время ответа не подсказывает нападающему, сколько байт угадано. Дальше свежесть: метка времени дальше пяти минут от текущей — отказ, и это защита от повтора перехваченного вызова. Кто хочет строже — держит короткий кэш виденных подписей в Redis и режет даже повтор внутри окна; честно скажу, для внутренних вызовов окно в пять минут почти всегда достаточно, а кэш добавляет зависимость, ложные срабатывания на ретраях и ещё один сервис, который может упасть раньше биллинга. Отдельная деталь — синхронизация часов: сервер и клиент со спешащими на минуты часами начнут терять честные вызовы по окну, поэтому NTP для внутренних машин — часть этой темы, а не отдельная забота.

> **Живое место: общий ключ и его история в git.** Первая версия почти всегда выглядит одинаково: один ключ на всю внутреннюю сеть, потому что так быстрее, и он же в закоммиченном config.yaml, потому что env настроить лень. Результат складывается из двух инцидентов: ключ навсегда остаётся в истории репозитория, и утечка любого из сервисов компрометирует все. Лечение: ключ на пару сервисов, никакого общего, и env вместо конфига. Смена скомпрометированного ключа без простоя требует двух ключей одновременно — старого на проверку и нового на выпуск — и это тот же протокол ротации, что у подписи jwt из [урока про JWKS](/courses/jwt-go/lessons/13-jwks/).

### Когда между сервисами нужен jwt

Сервисный jwt появляется там, где HMAC-подписи мало. Первый случай — контекст пользователя: нотификатор должен знать, чей заказ рассылать, и токен с полем act и sub пользователя сохраняет сквозную авторизацию без пересылки паролей. Второй — много вызываемых: подписывать запрос пятью разными ключами и хранить пять секретов в каждом клиенте хуже, чем выпускать короткие токены с аудиторией. Третий — внешний подрядчик: свой ключ давать страшно, а токен на пять минут с aud, ограничивающим один эндпоинт, — нормальный контракт. Сам выпуск — это RegisteredClaims с полями iss, sub, aud и коротким exp; проверяющий вызывает ParseWithClaims с опциями WithIssuer и WithAudience, и они сверяются наравне с подписью. Если проверяющих много и ключи надо раздавать публично — маршрут ведёт к [JWKS](/courses/jwt-go/lessons/13-jwks/) и архитектуре из [урока про микросервисы](/courses/jwt-go/lessons/22-jwt-mikroservisy/).

### Сервисный JWT: пять минут и аудитория

```go
func ServiceToken(caller, audience string) (string, error) {
        claims := jwt.RegisteredClaims{
                Issuer:    caller,
                Subject:   caller,
                Audience:  jwt.ClaimStrings{audience},
                ID:        newJTI(),
                IssuedAt:  jwt.NewNumericDate(time.Now()),
                ExpiresAt: jwt.NewNumericDate(time.Now().Add(5 * time.Minute)),
        }
        secret := []byte(os.Getenv("SERVICE_JWT_SECRET"))
        return jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString(secret)
}
```

Пять минут срока, издатель и аудитория ограничивают применение токена одним вызываемым сервисом: даже утечка не открывает всю внутреннюю сеть. Проверяющая сторона передаёт в ParseWithClaims опции jwt.WithAudience и jwt.WithIssuer — несоответствие любого поля даёт ошибку до бизнес-логики. Для пары сервисов общий HS256-секрет честен; когда сервисов много, секрет меняют на пару RSA-ключей, и каждый проверяющий получает только публичный. Поле ID здесь — тот же jti, удобный для denylist при форс-отзыве.

### Способы аутентификации сервисов

| Способ | Что доказывает | Отзыв | Когда брать |
| --- | --- | --- | --- |
| Статичный API-ключ | владение секретом | правкой конфига | пара сервисов, простой трафик |
| HMAC-подпись запроса | владение секретом и целостность запроса | правкой конфига | то же, но с защитой от повтора и подмены тела |
| Сервисный JWT | личность вызова с aud и сроком | истечением exp и denylist по jti | контекст пользователя, много вызываемых, подрядчики |
| mTLS | владение сертификатом на уровне соединения | отзывом сертификата | строгие регуляторные сети |

Каким бы ни был токен, секрет живёт в env или менеджере секретов и попадает в контейнер при деплое, а не в репозиторий. Ротация статичного ключа делается двойным ключом: проверяющий временно принимает старый и новый одновременно, клиент переключается на новый, через сутки старый удаляется — downtime нулевой. У сервисного jwt ротация встроена: короткий exp сам выводит токены из оборота, а ключ подписи меняется по протоколу из JWKS-урока. Полезный слой сверх аутентификации — лимит по ключу: rate limiter на Redis считает вызовы каждого X-Service-ID отдельно, и всплеск от одного клиента не съедает квоту остальных; механика в [уроке про rate limiter курса go-redis](/courses/go-redis/lessons/22-rate-limiter/). Неудачные попытки аутентификации пишутся в лог с именем сервиса из заголовка — не секретом — и алерт на их всплеск находит утечку раньше владельца ключа. Все четыре способа из таблицы выше решают одну задачу с разной ценой; выбирать стоит от количества пар сервисов и требований регулятора, а не от моды на слово mTLS.

> **Логируйте, кто не прошёл.** На отказе аутентификации пишите X-Service-ID, метку времени и причину: подпись не сошлась, время врозь, ключ неизвестен. Сам ключ и подпись в лог не попадают — это секреты. Такая строка стоит минуты, а при утечке отличает чужую машину от своего клиента, сломанного после релиза.

### Проверка: защита от повтора

**Вопрос:** Что не позволит нападающему повторить перехваченный HMAC-запрос через два часа?

- Сама подпись — она одноразовая
- ✅ Метка времени в подписи и окно свежести на сервере
- HTTPS-сертификат вызываемого сервиса
- Длина ключа

Подпись покрывает метку времени, а сервер отвергает запросы, где X-Timestamp уходит за пределы окна — обычно пяти минут. Перехватить трафик HTTPS нападающий в нормальной сети не может, но подписи утекают и через логи, и через скриншоты — окно закрывает ровно эту дыру. Поэтому окно не делают широким: каждая минута ширины — минута жизни перехваченного вызова. Одноразовости у HMAC самой по себе нет — это детерминированная функция, и те же байты дают ту же подпись; одноразовость привносит именно метка времени плюс серверное окно.

### Проверка: утечка сервисного jwt

**Вопрос:** Сервисный JWT с aud=notifications попал к нападающему. Что он сможет вызвать с ним?

- Любой внутренний сервис, пока не истечёт exp
- ✅ Только сервис notifications и только до exp
- Ничего: токен привязан к IP вызывавшего
- Только read-эндпоинты

Аудитория ограничивает применение: проверяющие с другим именем отвергнут токен опцией WithAudience, а срок в пять минут не оставляет времени на масштабирование утечки. Привязки к IP в стандартных claims нет — её добавляют отдельно, если сетевая топология позволяет. Read-эндпоинты защищаются не типом токена, а правами в aud. Отдельный плюс короткого exp — ротация без отдельного протокола: токены вымирают сами, и смена ключа подписи переживается за одну пятиминутку.

### Повтор старого запроса

```go
// проверяющая сторона получила запрос:
// X-Timestamp на два часа раньше текущего времени
// X-Signature пересчитанный сервером HMAC совпал

// что вернёт проверка?
```

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

- 200: подпись верна, время не проверяется
- ✅ 401: метка времени за пределами окна свежести
- 200 с предупреждением в логе
- 500: сервер не умеет сравнивать время

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

### Восстановите вычисление подписи

Допишите HMAC-подпись: создание mac с алгоритмом хеширования и кодирование суммы.

```go
mac := hmac.New(sha256.___, key)
mac.Write([]byte(method + " " + path + " " + ts + " "))
mac.Write(body)
sig := hex.___(mac.Sum(nil))
```

Подсказка: hmac.New принимает функцию хеширования — sha256.New, а сумма кодируется в hex через hex.EncodeToString.

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

### Соберите подписанный вызов

Расставьте строки клиента в правильном порядке: от чтения ключа до отправки запроса.

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

1. `key := []byte(os.Getenv("BILLING_KEY"))`
2. `payload, _ := json.Marshal(order)`
3. `ts, sig := signRequest("POST", "/api/charge", payload, key)`
4. `req, _ := http.NewRequest("POST", "http://billing/api/charge", bytes.NewReader(payload))`
5. `req.Header.Set("X-Signature", sig)`
6. `resp, err := http.DefaultClient.Do(req)`

### Карточки: сервис-сервис

- **API-ключ** — Статичный секрет пары сервисов: живёт в env, прикладывается к запросу, отзывается правкой конфига. Каждый запрос несёт одно и то же — перехват действителен до ротации.
- **HMAC-подпись запроса** — HMAC-SHA256 от метода, пути, метки времени и тела. Секрет по сети не ходит, подмена любого поля ломает подпись, окно свежести режет повторы.
- **X-Timestamp** — Метка времени в подписанной строке: сервер отвергает запросы вне окна, обычно пяти минут. Защита от повтора перехваченного вызова.
- **aud** — Аудитория сервисного jwt: имя вызываемого сервиса. Проверяется опцией WithAudience и ограничивает утечку одним адресатом.
- **Ротация двойным ключом** — Проверяющий временно принимает старый и новый ключ, клиент переезжает на новый, старый удаляется. Смена без простоя для статичных ключей.

Межсервисная история смыкается с пользовательской: внутри сервисного вызова может ехать jwt пользователя с его правами, и проверять его будет gateway — об этом [урок про JWT в микросервисах](/courses/jwt-go/lessons/22-jwt-mikroservisy/). Криптографическая база темы — HMAC из [пятого урока](/courses/jwt-go/lessons/05-hmac-podpis/) и асимметричные подписи из [RS256](/courses/jwt-go/lessons/12-rsa-i-es/). Клиентская сторона обычного браузерного запроса с тем же заголовком Authorization разобрана в [уроке про fetch и Bearer](/courses/jwt-go/lessons/18-avtorization-zagolovok/), чек-лист по хранению секретов собран в [уроке про лучшие практики](/courses/jwt-go/lessons/24-luchshie-praktiki/). Всё из этого урока повторяется руками на локальном Go: два сервиса на разных портах и env с ключом — весь стенд. Вернуться к оглавлению можно [на странице курса](/courses/jwt-go/).

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

- Сервисные токены go — идентификация машин, а не людей: пользовательский jwt для этого не годится.
- API-ключи против jwt: ключ проще и отзывается правкой, jwt короткоживущий и несёт aud и sub.
- HMAC-подпись запроса покрывает метод, путь, время и тело; сравнение — в постоянное время.
- Сервисный jwt живёт пять минут и проверяется с WithIssuer и WithAudience.
- Ротация двойным ключом меняет секреты без простоя; ключи живут в env, не в git.
- Лимит и лог отказов по X-Service-ID находят утечку раньше владельца ключа.

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

Соберите стенд на локальном Go: два хендлера на разных портах, ключ в env, подпись запроса по образцу урока. Прогоните честный вызов, затем подмените тело на стороне клиента и убедитесь, что подпись отвалилась; сдвиньте X-Timestamp на десять минут назад и получите отказ по окну. В конце поменяйте ключ в env только у клиента — проверяющая сторона обязана молча вернуть 401 и записать в лог имя сервиса. Запишите время, которое ушло на стенд: оно равно времени внедрения в реальном проекте, и это лучший аргумент против хождения без аутентификации.

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

### Не проще ли ходить между сервисами вообще без аутентификации внутри VPN?

Внутри доверенной сети аутентификация всё равно нужна: VPN защищает периметр, а не направление запроса, и один скомпрометированный под становится точкой входа. Минимальная схема с ключом из env стоит минуты и оставляет в логах, кто именно вызвал сервис.

### Почему сравнивать ключи нужно в постоянное время?

Обычное сравнение строк выходит при первом несовпадающем байте, и микроскопические различия времени ответа позволяют угадывать ключ по байтам. crypto/subtle.ConstantTimeCompare тратит одинаковое время независимо от позиции несовпадения — атака теряет канал.

### Где взять большое случайное значение для ключа?

Генератором паролей или openssl rand -hex 32 в терминале — получите 256 бит энтропии. Не используйте UUID: у него энтропии достаточно для идентификатора, но привычка к uuid-ключам приводит к ключам из инкрементов в старых системах. Ключ раз в пару лет стоит ротировать даже без компрометации — процедура должна быть наезженной до инцидента, а не во время него.
