Первый внутренний вызов появляется невинно: заказ сохранился — нужно уведомить рассыльщик, отчёт собрался — обновить биллинг. И здесь же совершается самая частая ошибка темы: для межсервисного вызова берут пользовательский jwt из тестового скрипта или заводят в базе служебного пользователя с паролем. Оба варианта ломают модель: токен привязан к человеку и умрёт вместе с его сессией, а у служебной учётки есть пароль, который можно перебрать, забыть сменить и утечь вместе с дампом. Машине нужен собственный идентификатор: сервисные токены в Go — это либо статичный API-ключ с подписью запроса, либо короткоживущий jwt с аудиторией, в которой записан вызываемый сервис. Разберём оба варианта, сравним api ключи vs jwt по честным критериям и соберём рабочую пару клиент-проверяющий. Всё, что здесь строится, не требует ни внешних сервисов, ни consul с vault: два процесса и две переменные окружения — уже рабочий стенд.
API-ключи против JWT между сервисами#
Статичный API-ключ — длинная случайная строка, одна на пару сервисов: клиент держит её в переменной окружения и прикладывает к запросу, проверяющий сравнивает со своей копией — в идеале в постоянное время, чтобы не подсказывать перебор по времени ответа. Просто, отзывается правкой конфига, но каждый запрос несёт один и тот же секрет: кто перехватил, владеет навсегда, пока вы не сменили. JWT-вариант переворачивает свойство: секрет по сети не ходит, вместо него летит подписанный токен на пять минут с полями sub — кто зовёт, aud — кого зовут — и сроком. Утёкший токен мёртв сам собой, зато появляется инфраструктура: выпуск, проверка, часы, распределение ключей. Моя граница выбора проста: пара сервисов с внутренним трафиком живёт с HMAC-подписью запроса; как только вызываемых становится больше двух-трёх или в вызов нужно положить контекст пользователя — берём короткоживущий jwt и дальше асимметрию из урока про RS256. Отдельный фактор — аудит: у статичного ключа все вызовы выглядят одинаково, а jwt с iss и jti оставляет в логах, кто, когда и каким вызовом пришёл — при разборе инцидентов это половина картины.
API-ключ в env и HMAC-подпись вызова
GOfunc 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-секрета из пятого урока. Подпись покрывает не только payload: метод и путь не дают переподставить запрос на другой эндпоинт, а время отсекает повтор через час. Подмена любого поля — тела, пути, метки — делает подпись недействительную. Тело подписывается как есть — байты, которые уйдут по сети: сначала подписал, потом изменил payload — и проверка развалится, что и требуется.
Проверяющая сторона симметрична и коротка. Сервис достаёт из заголовков X-Service-ID, X-Timestamp и X-Signature, берёт ключ этого сервиса из своего env — у каждой пары свой — и пересчитывает HMAC от тех же полей. Сравнивать подписи надо в постоянное время: crypto/subtle.ConstantTimeCompare не выходит раньше при первом несовпадающем байте, и время ответа не подсказывает нападающему, сколько байт угадано. Дальше свежесть: метка времени дальше пяти минут от текущей — отказ, и это защита от повтора перехваченного вызова. Кто хочет строже — держит короткий кэш виденных подписей в Redis и режет даже повтор внутри окна; честно скажу, для внутренних вызовов окно в пять минут почти всегда достаточно, а кэш добавляет зависимость, ложные срабатывания на ретраях и ещё один сервис, который может упасть раньше биллинга. Отдельная деталь — синхронизация часов: сервер и клиент со спешащими на минуты часами начнут терять честные вызовы по окну, поэтому NTP для внутренних машин — часть этой темы, а не отдельная забота.
Первая версия почти всегда выглядит одинаково: один ключ на всю внутреннюю сеть, потому что так быстрее, и он же в закоммиченном config.yaml, потому что env настроить лень. Результат складывается из двух инцидентов: ключ навсегда остаётся в истории репозитория, и утечка любого из сервисов компрометирует все. Лечение: ключ на пару сервисов, никакого общего, и env вместо конфига. Смена скомпрометированного ключа без простоя требует двух ключей одновременно — старого на проверку и нового на выпуск — и это тот же протокол ротации, что у подписи jwt из урока про JWKS.
Когда между сервисами нужен jwt#
Сервисный jwt появляется там, где HMAC-подписи мало. Первый случай — контекст пользователя: нотификатор должен знать, чей заказ рассылать, и токен с полем act и sub пользователя сохраняет сквозную авторизацию без пересылки паролей. Второй — много вызываемых: подписывать запрос пятью разными ключами и хранить пять секретов в каждом клиенте хуже, чем выпускать короткие токены с аудиторией. Третий — внешний подрядчик: свой ключ давать страшно, а токен на пять минут с aud, ограничивающим один эндпоинт, — нормальный контракт. Сам выпуск — это RegisteredClaims с полями iss, sub, aud и коротким exp; проверяющий вызывает ParseWithClaims с опциями WithIssuer и WithAudience, и они сверяются наравне с подписью. Если проверяющих много и ключи надо раздавать публично — маршрут ведёт к JWKS и архитектуре из урока про микросервисы.
Сервисный JWT: пять минут и аудитория
GOfunc 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. Неудачные попытки аутентификации пишутся в лог с именем сервиса из заголовка — не секретом — и алерт на их всплеск находит утечку раньше владельца ключа. Все четыре способа из таблицы выше решают одну задачу с разной ценой; выбирать стоит от количества пар сервисов и требований регулятора, а не от моды на слово mTLS.
На отказе аутентификации пишите X-Service-ID, метку времени и причину: подпись не сошлась, время врозь, ключ неизвестен. Сам ключ и подпись в лог не попадают — это секреты. Такая строка стоит минуты, а при утечке отличает чужую машину от своего клиента, сломанного после релиза.
Проверка: защита от повтора
Что не позволит нападающему повторить перехваченный HMAC-запрос через два часа?
Проверка: утечка сервисного jwt
Сервисный JWT с aud=notifications попал к нападающему. Что он сможет вызвать с ним?
Повтор старого запроса
Прочитайте код и выберите, что он выведет:
// проверяющая сторона получила запрос:
// X-Timestamp на два часа раньше текущего времени
// X-Signature пересчитанный сервером HMAC совпал
// что вернёт проверка?Восстановите вычисление подписи
Допишите HMAC-подпись: создание mac с алгоритмом хеширования и кодирование суммы.
Соберите подписанный вызов
Расставьте строки клиента в правильном порядке: от чтения ключа до отправки запроса.
Строки
Ваша программа
Нажимайте на строки слева, чтобы собрать программу
Карточки: сервис-сервис
Межсервисная история смыкается с пользовательской: внутри сервисного вызова может ехать jwt пользователя с его правами, и проверять его будет gateway — об этом урок про JWT в микросервисах. Криптографическая база темы — HMAC из пятого урока и асимметричные подписи из RS256. Клиентская сторона обычного браузерного запроса с тем же заголовком Authorization разобрана в уроке про fetch и Bearer, чек-лист по хранению секретов собран в уроке про лучшие практики. Всё из этого урока повторяется руками на локальном Go: два сервиса на разных портах и env с ключом — весь стенд. Вернуться к оглавлению можно на странице курса.
Песочница урока
Редактируйте код и запускайте его прямо здесь — интерпретатор работает в браузере, ничего устанавливать не нужно. За каждый запуск — +2 XP.
Запустите код, чтобы увидеть вывод.
Коротко: что запомнить
- Сервисные токены 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-ключам приводит к ключам из инкрементов в старых системах. Ключ раз в пару лет стоит ротировать даже без компрометации — процедура должна быть наезженной до инцидента, а не во время него.
Термины урока
Ключевые понятия из этого урока — короткие определения в глоссарии. Наведите на термин, чтобы вспомнить, откройте карточку для подробностей и ссылки на практику.
Изучайте дальше
Следующая ступень учебного пути
Telegram-боты на Go: от эха до полноценного сервиса
Учимся писать Telegram-ботов на Go: токен BotFather, команды, клавиатуры, состояния диалога, медиа, рассылки, webhook и деплой. 25 уроков с кодом.
- Создавать бота через BotFather и хранить токен безопасно
- Получать обновления через long polling и webhook
- Отвечать сообщениями, командами и клавиатурами
- Строить многошаговые диалоги с сохранением состояния