Webhook в Go не проходит HMAC: проверяем подпись исходного тела
Платёжный сервис отправил webhook с корректной подписью, а обработчик ответил 401. В журнале JSON выглядел одинаково. Проверка показала, что приложение сначала разобрало тело в map, затем снова закодировало его и посчитало HMAC. Пробелы и порядок представления изменились: логический объект остался тем же, подписанная последовательность байтов стала другой.
Определите точный вход подписи
HMAC вычисляется для байтов, согласованных сторонами протокола. Если провайдер подписывает исходное HTTP-тело, проверять нужно именно прочитанное тело, прежде чем разбирать JSON. Преобразование переносов строк, добавление пробела или повторная сериализация меняет подпись. Если протокол включает timestamp, путь или префикс, их добавляют в строго установленном порядке. Этот порядок берут из контракта конкретного отправителя.
В примере договор простой: заголовок содержит hex-код HMAC-SHA256 тела, ключ заранее известен обеим сторонам. Это позволяет увидеть причину отказа без внешнего платёжного API. В рабочем webhook сначала ограничивают размер чтения, затем проверяют формат подписи, сравнивают MAC и только после этого разбирают событие. Не используйте один секрет для разных независимых интеграций.
Сравните исходные и нормализованные байты
Программа подписывает JSON с пробелами, проверяет его, затем разбирает и собирает JSON заново. Повторное кодирование удалит пробелы, поэтому прежняя подпись перестанет подходить. Проверка использует hmac.Equal, а не сравнение строк: эта функция предназначена для сравнения MAC без утечки через время сравнения содержимого.
package main
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
)
func sign(body, key []byte) []byte { m := hmac.New(sha256.New, key); m.Write(body); return m.Sum(nil) }
func valid(body, key []byte, signature string) bool {
supplied, err := hex.DecodeString(signature)
if err != nil {
return false
}
return hmac.Equal(sign(body, key), supplied)
}
func main() {
key := []byte("local-test-key")
raw := []byte(`{"event_id": "E7", "paid": true}`)
signature := hex.EncodeToString(sign(raw, key))
fmt.Println(valid(raw, key, signature))
var value map[string]any
if err := json.Unmarshal(raw, &value); err != nil {
panic(err)
}
normalized, err := json.Marshal(value)
if err != nil {
panic(err)
}
fmt.Println(valid(normalized, key, signature))
fmt.Println(valid(raw, key, "not-hex"))
}
Вывод true/false/false разделяет три случая: исходный запрос принимается, изменённое представление отвергается, неправильный hex тоже отвергается. Последний случай важно обрабатывать как обычный некорректный вход, без паники и без передачи текста ошибки декодирования во внешний ответ. В серверный журнал достаточно записать категорию отказа и идентификатор интеграции.
Передайте те же байты бизнес-обработчику
После успешной проверки исходный буфер можно передать json.Unmarshal. Повторно читать r.Body без восстановления потока нельзя: первое чтение уже израсходовало данные. Если архитектура требует следующего middleware, восстановите тело через новый reader либо передайте проверенный объект явно. Не логируйте весь буфер ради отладки подписи: webhook может содержать персональные и платёжные данные.
Для ограничения тела пригодится MaxBytesReader и обработка 413. Лимит ставят до полного чтения. Ошибка чтения должна прекращать проверку: подпись усечённого тела не представляет подпись исходного запроса. Значение лимита согласуйте с максимальным размером события и необходимым запасом.
Корректная HMAC доказывает соответствие тела и ключа, но сама по себе не запрещает повтор этого же сообщения. Если отправитель повторно доставляет webhook после таймаута, подпись снова будет корректной. Для локального изменения данных сохраните устойчивый event_id с уникальным ограничением в той же транзакции, что и результат обработки. Повтор тогда сможет вернуть согласованный успешный ответ без второй операции.
Если протокол подписывает время, проверьте допустимое окно и синхронизацию часов. Старую корректную подпись можно отклонить по времени, но окно не заменяет дедупликацию внутри него. При ротации ключей согласуйте период, в котором допустимы прежний и новый ключ, и записывайте идентификатор использованного ключа без самого значения.
Набор тестов удобно строить из сохранённых тестовых байтов: правильная подпись, один изменённый байт, неправильный ключ, испорченный заголовок и повтор одного event_id. В случае настоящего провайдера добавьте его опубликованный тестовый вектор. Он проверит именно состав подписанного сообщения, который у разных webhook API отличается.
Примите решение до побочного эффекта
Поставьте в тестовый обработчик счётчик сохранённых событий. Неверная подпись должна оставить его равным нулю даже при полностью корректном JSON. Для принятого события отдельно проверьте ошибку базы: отправитель обычно решает повторять доставку по HTTP-статусу, поэтому успешный ответ до commit может потерять событие. Если требуется быстро подтверждать приём, сначала сохраните событие в устойчивой очереди или таблице, затем возвращайте согласованный успешный статус. Дальнейшая обработка становится отдельным этапом со своим состоянием и возможностью повторного запуска, а исходное тело больше не зависит от жизни HTTP-запроса.
Продолжите до взаимодействия сервисов
После проверки подписи событие нужно устойчиво принять и обработать повторно без второго результата. В платном курсе микросервисов на Go вы разберёте транзакции PostgreSQL, доставку Kafka и связку outbox/inbox. Разборы сбоев помогают определить, что осталось в базе после отказа и с какого шага продолжить обработку. Откройте программу курса «Микросервисы на Go: HTTP, PostgreSQL, gRPC, Kafka и Docker».
Продолжить на практике
- Микросервисы на Go: HTTP, PostgreSQL, gRPC, Kafka и Docker ↗
От работающего Go API — к взаимодействию сервисов, которому можно доверять. Разберитесь, как проектировать контракты, сохранять согласованность данных и восстанавливать обработку после сбоев. HTTP, PostgreSQL, gRPC, Kafka и Docker — через код и наглядные разборы.
