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 — через код и наглядные разборы.