Подпись вебхуков: безопасная проверка без replay-атак
Проверка вебхука по одному секретному токену не защищает от подмены и повторной доставки. Разбираем схему HMAC с сырым телом запроса, меткой времени, защитой от replay-атак и ротацией ключей.

Вебхук, который проверяет только наличие секретного токена, можно подделать или повторно отправить. Надёжная схема должна подтвердить сразу три вещи: запрос подписал нужный отправитель, тело не изменилось, а сообщение не является старой копией. Ниже — практическая реализация на Python 3.12 с HMAC-SHA-256, меткой времени и защитой от повторной доставки.
Что именно должна гарантировать подпись
Вебхук приходит как обычный HTTP-запрос. Между отправителем и вашим сервером могут быть прокси, балансировщик, логирование и повторная доставка. Поэтому проверять нужно не «похожесть» JSON, а криптографическую подпись тех байтов, которые отправитель действительно передал.
- Подлинность отправителя. Только сторона с секретным ключом может вычислить правильный HMAC.
- Целостность. Изменение даже одного пробела в теле меняет подпись.
- Свежесть. Метка времени ограничивает окно, в котором запрос принимается.
- Однократность обработки. Идентификатор события не даёт повторно выполнить тот же платёж, заказ или команду.
Подпись доказывает, кто сформировал сообщение и что в нём изменилось. Она не доказывает, что сообщение ещё не обрабатывали.
HMAC — код аутентификации сообщения на основе хеш-функции и общего секретного ключа. В этой схеме отправитель и получатель заранее знают один и тот же секрет, но передают его не в запросе.
Схема, которую стоит зафиксировать до написания кода
У HMAC нет единого обязательного формата HTTP-заголовков. Его нужно описать в контракте вебхука и одинаково реализовать с обеих сторон. В примере используются такие поля:
X-Webhook-Timestamp— Unix-время в секундах;X-Webhook-Signature— HMAC-SHA-256 в шестнадцатеричном виде;X-Webhook-Id— уникальный идентификатор события.
Подписываемая строка строится так: timestamp + "." + raw_body. Сначала timestamp переводится в ASCII-байты, затем добавляется точка и исходное тело запроса без разбора JSON. Отправитель вычисляет HMAC секретным ключом, а получатель повторяет эту операцию.
| Решение | Что защищает | Цена и ограничение |
|---|---|---|
| Секретный токен в URL | Минимальная аутентификация | Попадает в логи, историю прокси и мониторинг; не защищает тело от изменений |
| HMAC только от тела | Источник и целостность сообщения | Перехваченный запрос можно повторить |
| HMAC от timestamp и тела | Источник, целостность и ограниченное окно доставки | Нужны синхронизация времени и защита от повторов |
| HMAC плюс event ID и хранилище | Полная защита схемы от повторной обработки | Нужно хранить обработанные идентификаторы и продумать срок их жизни |
Реализация на Python 3.12
Функции ниже используют только стандартную библиотеку Python. Они не привязаны к Flask, Django или конкретному фреймворку: передайте в них необработанное тело запроса и значения заголовков, полученные от HTTP-сервера.
Подпись на стороне отправителя
Секрет не должен попадать в исходный код. В рабочем сервисе загрузите его из секретного хранилища или переменной окружения. В примере используется безопасная заглушка.
import hashlib
import hmac
import time
def make_signature(raw_body: bytes, secret: bytes, timestamp: int) -> str:
signed_data = str(timestamp).encode("ascii") + b"." + raw_body
return hmac.new(
secret,
signed_data,
hashlib.sha256,
).hexdigest()
raw_body = b'{"event":"payment.succeeded","id":"evt_123"}'
secret = b"YOUR_WEBHOOK_SECRET"
timestamp = int(time.time())
signature = make_signature(raw_body, secret, timestamp)
print(timestamp)
print(signature)
Важно сохранить тело ровно в том виде, в котором оно будет отправлено. Если сначала сериализовать объект в JSON, форматирование, порядок полей или экранирование могут измениться — и подпись получателя не совпадёт.
Проверка на стороне получателя
Проверка должна идти до разбора JSON и до запуска бизнес-операции. В реальном приложении ответ с ошибкой должен быть единообразным: не сообщайте отдельно, что неверны timestamp, подпись или идентификатор события.
import hashlib
import hmac
import time
class InvalidWebhook(Exception):
pass
def verify_webhook(
raw_body: bytes,
secret: bytes,
timestamp_header: str,
signature_header: str,
now: int | None = None,
max_age: int = 300,
) -> None:
try:
timestamp = int(timestamp_header)
except ValueError as error:
raise InvalidWebhook("invalid webhook") from error
current_time = int(time.time()) if now is None else now
if abs(current_time - timestamp) > max_age:
raise InvalidWebhook("invalid webhook")
signed_data = str(timestamp).encode("ascii") + b"." + raw_body
expected_signature = hmac.new(
secret,
signed_data,
hashlib.sha256,
).hexdigest()
if not hmac.compare_digest(expected_signature, signature_header):
raise InvalidWebhook("invalid webhook")
hmac.compare_digest нужен для сравнения без обычного раннего выхода при первом несовпавшем символе. Это снижает риск атак по времени выполнения. Функция не должна сама читать тело запроса, отвечать клиенту или менять базу данных: эти обязанности относятся к слою HTTP и обработчику событий.
Почему одной метки времени недостаточно
Окно в пять минут ограничивает срок действия перехваченного запроса, но не запрещает отправить его повторно внутри этого окна. Если обработчик создаёт заказ, списывает деньги или меняет права пользователя, этого недостаточно.
После успешной проверки подписи извлеките из JSON поле event_id или используйте значение заголовка X-Webhook-Id. Запишите его в хранилище с уникальным ограничением. Если такой идентификатор уже есть, верните успешный ответ без повторного выполнения операции.
Порядок действий должен быть таким:
- Прочитать сырые байты тела запроса.
- Проверить наличие и формат timestamp.
- Проверить допустимое окно времени.
- Вычислить HMAC по timestamp и сырым байтам.
- Сравнить подписи через защищённую функцию сравнения.
- Распарсить JSON и проверить схему события.
- Атомарно проверить и сохранить идентификатор события.
- Запустить бизнес-операцию или поставить её в очередь.
Что ломается на практике
Тело распарсили и сериализовали заново
Парсер JSON может убрать пробелы, изменить порядок ключей или преобразовать числа. Подпись нужно проверять по исходным байтам, а не по результату json.dumps или аналога в другом языке.
Сравнили подписи оператором равенства
Обычное сравнение может завершиться раньше при первом несовпадении. Используйте криптографически безопасное сравнение, доступное в стандартной библиотеке вашего языка. Название функции отличается между языками, поэтому проверяйте документацию конкретной версии.
Приняли timestamp из тела события
Если время берётся из JSON, отправитель может подписать одно значение, а обработчик будет проверять другое. Источник timestamp должен быть заранее определён контрактом. В этой схеме это HTTP-заголовок, входящий в подписываемую строку.
Поставили слишком широкое окно
Окно в несколько часов увеличивает срок, когда перехваченный запрос можно повторить. Начните с пяти минут, если доставка через очереди не требует большего значения, и согласуйте его с политикой повторных попыток отправителя.
Секрет оказался в логах
Не логируйте секрет, полную строку подписи и заголовки без фильтрации. Сырые тела запросов тоже могут содержать персональные данные и платёжную информацию. Для расследования обычно достаточно event ID, результата проверки, времени и технического request ID.
Ротация ключей без остановки вебхуков
Секрет нельзя менять одним мгновенным переключением, если отправитель и получатель обновляются независимо. На короткий переходный период получатель может проверять подпись новым и предыдущим ключом, но подписывать новые запросы отправитель уже должен новым ключом.
Для этого добавьте в контракт идентификатор ключа, например X-Webhook-Key-Id, либо заранее договоритесь о порядке перебора активных ключей. Старый ключ удаляйте после максимального времени доставки, периода повторных попыток и проверки логов. Само наличие двух активных ключей не заменяет проверку timestamp и event ID.
Где подход ломается
HMAC не решает проблему скомпрометированного сервера отправителя или получателя: имея секрет, злоумышленник сможет создавать корректные подписи. Он также не шифрует тело. Если в вебхуке есть конфиденциальные данные, используйте TLS и не передавайте больше данных, чем нужно обработчику.
Есть и операционные границы. При большом количестве повторов обработчик должен быть идемпотентным, а тяжёлая работа — уходить в очередь после быстрой проверки. Если часы на сервере сильно расходятся, честные запросы начнут отклоняться. Синхронизация времени и мониторинг доли ошибок обязательны.
Что проверить перед запуском
- Подписываются timestamp и сырые байты тела, а не распарсенный объект.
- Подпись сравнивается через защищённую функцию.
- Старый timestamp отклоняется.
- Повторный event ID не выполняет бизнес-операцию второй раз.
- Секреты отсутствуют в репозитории, логах и сообщениях об ошибках.
- Ротация ключей и повторные доставки проверены на стенде.
- Алерт сообщает о всплеске неверных подписей, но не раскрывает ожидаемое значение.
Что делать дальше
Сначала зафиксируйте формат подписи в контракте: алгоритм, кодировку, состав подписываемых данных, окно времени, заголовки и правила повторной доставки. Затем добавьте тесты на изменённый байт тела, просроченный timestamp, неверный секрет, повторный event ID и смену ключа. В продакшене измеряйте долю отклонённых вебхуков, задержку между timestamp и получением запроса, число повторов и время обработки после успешной проверки.
Проверка грамотности: Орфография, пунктуация, стиль — с объяснением каждой правки
Спросить за 10 ₽Источники: Python 3.12 documentation: hmac — Keyed-Hashing for Message Authentication, RFC 2104: HMAC: Keyed-Hashing for Message Authentication
Частые вопросы
Можно ли подписывать только JSON-тело без timestamp?
Какое окно времени выбрать?
Нужно ли хранить все полученные подписи?
Что делать, если подпись не совпадает после подключения прокси?
Как менять секрет без остановки вебхуков?
Достаточно ли HMAC, если вебхук содержит персональные данные?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.