Как проверять подпись вебхуков через HMAC и защититься от подделки
Практика4 минРедакция Innovialabs
Коротко
Пересчитайте HMAC от сырого тела запроса (и timestamp) общим секретом, сравните с подписью за константное время, отклоняйте старые timestamp и повторные ID событий — и только потом доверяйте вебхуку и обрабатывайте его.
Коротко
URL вебхука публичен: любой, кто его узнал, может отправить фальшивый запрос «оплата прошла». Подпись доказывает, что запрос отправил тот, кто знает общий секрет, и что тело не изменено. Чтобы её проверить:
- Прочитайте сырое тело ровно в том виде, в каком оно пришло, до парсинга JSON.
- Вычислите
HMAC-SHA256(secret, timestamp + "." + body)или ту строку, которую описывает провайдер. - Сравните с заголовком подписи за константное время.
- Отклоняйте запросы со старым timestamp и уже обработанными ID событий.
- Только после этого парсите и обрабатывайте данные.
Как работает HMAC
HMAC — это хеш-функция, объединённая с секретным ключом. Отправитель и получатель знают секрет; отправитель считает HMAC сообщения и кладёт его в заголовок, получатель пересчитывает. Без секрета атакующий не создаст валидную подпись, а любое изменение тела меняет результат.
HMAC — не шифрование: тело остаётся читаемым, поэтому вебхуки должны идти по HTTPS.
Пример кода: Node.js
import crypto from "node:crypto";
export function verifyWebhook(rawBody, timestamp, signature, secret) {
const age = Math.abs(Date.now() / 1000 - Number(timestamp));
if (!Number.isFinite(age) || age > 300) return false; // окно 5 минут
const expected = crypto
.createHmac("sha256", secret)
.update(`${timestamp}.${rawBody}`)
.digest("hex");
const a = Buffer.from(expected, "hex");
const b = Buffer.from(String(signature), "hex");
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
В Express читайте тело через express.raw({ type: "application/json" }) на маршруте вебхука: повторная сериализация распарсенного JSON может изменить пробелы или порядок ключей и сломать подпись.
Пример кода: Python
import hashlib
import hmac
import time
def verify_webhook(raw_body: bytes, timestamp: str, signature: str, secret: bytes) -> bool:
if abs(time.time() - int(timestamp)) > 300:
return False
message = timestamp.encode() + b"." + raw_body
expected = hmac.new(secret, message, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, signature)
Зачем сравнение за константное время
Обычное == останавливается на первом несовпадающем символе. Измеряя время ответа, атакующий теоретически может подбирать подпись побайтно. crypto.timingSafeEqual и hmac.compare_digest работают одинаковое время независимо от того, где строки различаются.
Защита от повторной отправки (replay)
Валидный подписанный запрос можно перехватить и отправить снова. Работают две защиты вместе:
- Timestamp внутри подписываемой строки: отклоняйте запросы вне короткого окна. Timestamp обязательно должен входить в HMAC, иначе его просто поменяют.
- Идемпотентность по ID события: сохраняйте ID обработанных событий или транзакций и игнорируйте повторы. Провайдеры и сами легитимно повторяют вебхуки, так что это защищает и от двойного зачисления.
IP-allowlist
Некоторые провайдеры публикуют адреса, с которых приходят колбэки. Allowlist — полезный дополнительный слой, но не замена подписи:
- списки IP меняются, держите их в конфиге, а не в коде;
- за прокси или балансировщиком берите IP клиента только из заголовка, который ставит ваш собственный прокси, иначе его подделают.
Ротация секрета
- Храните секрет в менеджере секретов или переменных окружения, никогда в репозитории.
- Во время ротации поддерживайте два активных секрета: принимайте подпись, валидную для любого, затем удалите старый.
- Меняйте секрет сразу, если он мог утечь, например после ухода сотрудника с доступом.
- Отдельный секрет для каждого окружения и каждой интеграции.
Payme и Click
Локальные платёжные системы не всегда используют классический HMAC, поэтому строго следуйте их документации:
- Payme Merchant API вызывает ваш эндпоинт с HTTP Basic-аутентификацией, где паролем служит ключ мерчанта из кабинета. Проверяйте заголовок
Authorizationна каждом запросе сравнением за константное время и при несовпадении возвращайте ошибку, предусмотренную протоколом. Проверка IP источника — второй слой. - Click SHOP API присылает
sign_string— MD5-хеш полей запроса, склеенных с вашим секретным ключом. Соберите строку в описанном порядке полей, захешируйте и сравните. Набор полей у запросов Prepare и Complete отличается.
В обоих случаях дополнительно сверяйте сумму и ID заказа со своей базой и делайте обработку идемпотентной: правильная подпись не значит, что сумма совпадает с заказом.
Частые ошибки
- Проверка подписи после парсинга и повторной сериализации JSON.
- Логирование полного запроса с заголовками, включая подпись или Basic-данные.
- Подробные ошибки, по которым атакующий понимает, какая проверка не прошла.
- Вебхук считается доказательством оплаты без проверки суммы и статуса.