Проверка подписи webhook: canonical input, replay protection и key rotation
У подписи webhook три разные границы: целостность сообщения, защита от повторной доставки и идемпотентность бизнес-события. Смешивать их в одну проверку опасно.
carrier → extract → context → process → inject → carrierТранспорт меняется; инварианты идентичности, версии и корреляции должны оставаться проверяемыми.

Подпись webhook решает узкую задачу: получатель проверяет, что сообщение пришло от стороны, владеющей нужным ключом, и что подписанные компоненты не были незаметно изменены. Она не заменяет валидацию payload, авторизацию бизнес-действия и защиту от повторной доставки.
Сначала договориться, что именно подписывается
Самая частая ошибка — одинаково назвать «body», но получить разные байты после нормализации JSON, перекодирования или изменения заголовков прокси. В протоколе должен быть определён canonical input: какие компоненты входят в подпись, в каком порядке и в каком представлении. Если используется стандарт вроде HTTP Message Signatures (RFC 9421), реализация должна следовать его модели, а не смешивать её с собственной HMAC-схемой.
Если провайдер использует custom signing contract, это тоже допустимо, но контракт нужно описать буквально: raw body или parsed fields, какие headers, какой timestamp, какой algorithm identifier, как кодируется signature. «Склеить строки примерно так же» — источник несовместимости.
Проверка подписи выполняется до доверия payload
Parser может понадобиться для маршрутизации, но чувствительное действие не должно начинаться раньше проверки. Ошибка подписи — отдельный security result. Её нельзя превращать в retry бесконечного бизнес-процесса или автоматически принимать сообщение через fallback без подписи.
Replay protection требует собственного состояния
Криптографически корректное сообщение можно отправить повторно. Поэтому полезны timestamp/created-at, допустимое временное окно и уникальный message/nonce identifier там, где семантика требует защиты от повторного применения. Сам по себе старый timestamp не всегда достаточно: повтор внутри разрешённого окна всё ещё возможен.
Idempotency на бизнес-уровне остаётся нужна даже при replay protection. Сеть может честно доставить одно и то же событие повторно, а consumer должен понимать, применял ли он уже эту версию.
Ротация ключа не должна создавать «слепое окно»
Получатель должен знать, каким key id проверять сообщение, и уметь пережить контролируемый overlap старого и нового ключа. В журнале сохраняют идентификатор ключа и результат проверки, но не сам секрет. Удаление старого ключа происходит после подтверждения, что отправитель действительно перешёл на новый.
Ошибки различать, но наружу не раскрывать лишнее
Внутри observability полезно отличать unknown key, invalid signature, expired timestamp, malformed signed components и duplicate nonce. Внешний ответ может быть более сдержанным, чтобы не превращать endpoint в oracle для перебора. Конкретное HTTP-поведение зависит от интеграционного контракта.
Тесты, которые дают больше уверенности, чем happy path
- изменить один байт body после подписи;
- поменять подписанный header;
- повторить корректное сообщение в пределах и за пределами допустимого окна;
- проверить неизвестный key id во время rotation;
- убедиться, что один и тот же business event не применяется дважды.
Если интеграционный тест проверяет только одну «правильную» подпись, он почти ничего не говорит о границах протокола. Надёжность проявляется в том, как система отвергает почти корректные сообщения.
Middleware не должно менять body до проверки
Автоматический JSON parser, decompression или proxy normalization может изменить представление сообщения раньше, чем verifier увидит исходные подписанные байты. Поэтому порядок middleware является частью security design. Интеграционный тест стоит запускать через тот же reverse proxy и application stack, что production, а не вызывать функцию проверки напрямую. Иначе локальный test проходит, а реальный endpoint отвергает корректные подписи или, хуже, проверяет уже другое представление.
Также стоит заранее решить, что делать при временной недоступности key discovery или metadata endpoint. Получатель не должен молча отключать verification. Безопаснее работать с последним валидным key set в ограниченном режиме и отдельно сигнализировать, что обновление ключей не удалось.
Документация интеграции должна содержать один проверяемый тестовый пример с известным входом и ожидаемым результатом проверки. Это снижает риск несовместимых реализаций у отправителя и получателя после обновления библиотек.
Проверяемость контракта важнее количества криптографических терминов в документации.
Получатель и отправитель должны одинаково воспроизводить проверку на одном наборе байтов.
Подпись и idempotency закрывают разные риски
Корректная signature говорит, что подписанные байты соответствуют владельцу ключа; она не гарантирует, что событие не было доставлено повторно. Поэтому consumer всё равно хранит предметный message/event id и безопасно обрабатывает duplicate delivery. В общей архитектуре это дополняет правила webhook/stream/backfill: transport может честно повторить сообщение, а бизнес-состояние должно примениться ровно так, как определено контрактом версии.
Ротацию ключей стоит прогонять в интеграционном окружении через тот же proxy и middleware, что используется в production. Именно на этой границе часто всплывают кеширование key set, нормализация заголовков и изменение body до verification. Проверка функции в unit test подтверждает криптографию, но не весь протокол. Сквозной тест показывает, переживёт ли реальный endpoint overlap старого и нового key id без слепого окна.