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

Вебхук может выглядеть как обычный POST-запрос, но его нельзя принимать на доверии: атакующий способен подменить тело, повторно отправить старое событие или подобрать подпись через измерение времени ответа. Надёжная схема обычно требует HMAC-SHA-256, проверки исходного тела запроса и метки времени. Ниже — практические приёмы для Node.js 20+ и критерии, по которым можно проверить свою интеграцию.
Сначала определите, что именно вы проверяете
До написания кода найдите в документации отправителя точную схему подписи. Один сервис подписывает только тело, другой — строку из времени и тела, третий передаёт несколько версий подписи в одном заголовке. Нельзя заменить эту схему «похожим» HMAC и считать интеграцию совместимой.
Зафиксируйте четыре параметра:
- алгоритм: например, HMAC-SHA-256;
- источник данных: сырые байты тела, JSON-текст или строка из нескольких полей;
- формат заголовка и кодировку подписи: hexadecimal или Base64;
- правило для времени, идентификатора события и повторной доставки.
Сырая нагрузка1 — это тело HTTP-запроса до разбора JSON и любой нормализации пробелов, порядка полей или кодировки.
Подпись доказывает целостность именно тех байтов, которые вы включили в расчёт. Если сначала распарсить JSON, а потом собрать его заново, это уже может быть другая строка.
Четыре подхода и их цена
В реальном проекте обычно выбирают между несколькими уровнями защиты. Они не равноценны: простой секрет в заголовке дешевле, но не защищает от подмены и повторной отправки.
| Подход | Что защищает | Цена и ограничение | Когда выбирать |
|---|---|---|---|
| Секретный заголовок | Случайного отправителя | Нет проверки тела, replay-защиты и безопасного сравнения | Только для временного прототипа во внутренней сети |
| HMAC тела | Целостность и подлинность тела | Нужно хранить общий секрет и не менять тело до проверки | Базовый вариант для большинства серверных вебхуков |
| HMAC тела и времени | Целостность и ограничение срока действия | Нужны корректные часы и защита от повторов | Платёжные, учётные и другие чувствительные события |
| Асимметричная подпись | Проверку открытым ключом без общего секрета | Сложнее ротация ключей, формат и хранение публичных ключей | Много потребителей или требование независимой верификации |
Рабочая схема HMAC для Node.js
Следующий пример использует распространённый формат: заголовок содержит t=<unix-time>,v1=<hex-signature>, а подписываемая строка собирается как timestamp + '.' + rawBody. Это не универсальный стандарт, а демонстрационный контракт. В вашей интеграции подставьте формат конкретного отправителя.
import { createHmac, timingSafeEqual } from 'node:crypto';
const MAX_CLOCK_SKEW_SECONDS = 300;
function parseSignatureHeader(headerValue) {
const parts = headerValue.split(',');
const fields = new Map();
for (const part of parts) {
const separatorIndex = part.indexOf('=');
if (separatorIndex === -1) continue;
const name = part.slice(0, separatorIndex).trim();
const value = part.slice(separatorIndex + 1).trim();
fields.set(name, value);
}
return {
timestamp: fields.get('t'),
signatureHex: fields.get('v1')
};
}
export function verifyWebhook(rawBody, signatureHeader, secret) {
if (typeof rawBody !== 'string') {
throw new TypeError('rawBody must be the original request body');
}
const { timestamp, signatureHex } = parseSignatureHeader(signatureHeader);
const timestampNumber = Number(timestamp);
if (!Number.isInteger(timestampNumber) || !signatureHex) {
return { valid: false, reason: 'malformed_signature' };
}
const age = Math.abs(Math.floor(Date.now() / 1000) - timestampNumber);
if (age > MAX_CLOCK_SKEW_SECONDS) {
return { valid: false, reason: 'stale_timestamp' };
}
const signedPayload = `${timestamp}.${rawBody}`;
const expectedSignature = createHmac('sha256', secret)
.update(signedPayload, 'utf8')
.digest();
let receivedSignature;
try {
receivedSignature = Buffer.from(signatureHex, 'hex');
} catch {
return { valid: false, reason: 'invalid_encoding' };
}
if (receivedSignature.length !== expectedSignature.length) {
return { valid: false, reason: 'invalid_length' };
}
const valid = timingSafeEqual(receivedSignature, expectedSignature);
return { valid, reason: valid ? 'ok' : 'signature_mismatch' };
}
Здесь проверка времени выполняется до криптографического сравнения, а подпись сравнивается через timingSafeEqual. Функция не должна сама принимать решение о повторной доставке: для этого нужен отдельный журнал обработанных событий.
Как встроить проверку в обработчик
Критический момент — получить оригинальное тело до JSON-парсинга. Если фреймворк записал в req.body уже готовый объект, восстановление JSON через JSON.stringify не гарантирует совпадение с исходными байтами. Настройте middleware или endpoint так, чтобы он сохранял raw body, а затем передайте его в проверку.
- Прочитайте заголовок подписи и сырое тело, не вызывая бизнес-логику. Проверка должна завершиться до записи заказа, выдачи доступа или запуска фоновой задачи.
- Проверьте формат заголовка, timestamp и допустимое окно времени. При ошибке верните отказ без подробностей о том, какая именно часть не совпала.
- Рассчитайте HMAC по точной строке из спецификации отправителя. Для JSON не сортируйте поля и не меняйте пробелы, если отправитель этого не требует.
- Сравните полученную подпись с ожидаемой в constant-time режиме. При совпадении только после этого разбирайте JSON и запускайте обработку.
- Сохраните уникальный идентификатор события с ограниченным сроком жизни. Если тот же идентификатор уже был обработан, не выполняйте действие повторно.
Типичные ошибки, которые ломают защиту
Подпись вычисляют после JSON.parse
Парсер удаляет часть синтаксических различий, а последующая сериализация может изменить порядок полей, экранирование или пробелы. С точки зрения HMAC это другой набор байтов. Сохраняйте raw body отдельно; объект для бизнес-логики создавайте после проверки.
Сравнивают строки обычным оператором
Проверка через received === expected может раскрывать информацию о совпавшем префиксе через время ответа. На практике сетевой шум усложняет такую атаку, но причина не использовать обычное сравнение всё равно остаётся. В Node.js применяйте timingSafeEqual и заранее проверяйте длину буферов: функция выбрасывает исключение при разных размерах.
Проверяют HMAC, но не timestamp
Старая настоящая посылка тоже имеет правильную подпись. Если платёжное событие можно отправить повторно через неделю, атакующий не обязан подделывать его. Окно времени, например пять минут, должно соответствовать документации отправителя и допустимому рассинхрону часов.
Считают timestamp достаточной защитой
В пределах пятиминутного окна одну и ту же посылку можно повторить много раз. Нужен idempotency key — уникальный идентификатор события, по которому система запоминает факт обработки. Храните его минимум дольше максимального окна повторной доставки, а для денежных операций срок согласуйте с бизнес-правилами.
Логируют секрет и полную посылку
Секрет нельзя помещать в URL, сообщения об ошибках, трассировки и обычные логи. Тело вебхука тоже может содержать персональные или платёжные данные. В логах оставляйте идентификатор события, результат проверки, возраст timestamp и хэш технических метаданных, если он нужен для расследования.
Ротация секрета без остановки интеграции
Смена ключа должна переживать период, когда отправитель и получатель обновляются не одновременно. На переходном этапе сервер может проверять подпись сначала новым секретом, затем старым. Старый ключ нельзя принимать бессрочно: задайте дату отключения, предупредите владельца интеграции и удалите его после подтверждения.
Не размещайте секрет в исходном коде и не передавайте его через переменные, которые случайно попадают в клиентский JavaScript. Используйте хранилище секретов или защищённые переменные окружения, ограничьте доступ к ним и проверьте, что диагностический дамп процесса их не раскрывает.
Когда что выбирать
Для одного внутреннего сервиса с контролируемой сетью HMAC тела часто достаточно, если endpoint закрыт TLS и повторная обработка сделана идемпотентной. Для публичного endpoint добавьте timestamp и хранилище идентификаторов событий. Асимметричные подписи оправданы, когда отправителей много, секрет нельзя безопасно разделить или проверка должна выполняться несколькими независимыми командами.
Не смешивайте транспортную и прикладную защиту. TLS скрывает запрос в пути, но не решает вопрос доверия к отправителю на сервере-получателе. HMAC подтверждает знание секрета, но не шифрует содержимое и не заменяет авторизацию действий.
Что проверить перед запуском
- В тесте изменение одного символа в теле приводит к отказу.
- Тело с теми же данными, но другим порядком JSON-полей обрабатывается ровно так, как требует спецификация.
- Запрос со старым timestamp отклоняется.
- Повтор события с тем же идентификатором не запускает действие второй раз.
- Подпись неверной длины не приводит к падению обработчика.
- В логах нет секрета, полного Authorization-заголовка и лишних персональных данных.
- После ротации принимается новый ключ, а старый перестаёт работать в запланированный срок.
Что делать дальше
Сначала составьте тестовый набор из реальных запросов отправителя: сохраните raw body, заголовки без секрета и ожидаемый результат. Затем добавьте метрики отказов по причинам stale_timestamp, signature_mismatch и повторного события. После запуска смотрите не только на долю ошибок, но и на всплески повторов, рассинхрон часов и задержку между приёмом вебхука и обработкой.
1 Raw body — исходное тело HTTP-запроса в виде байтов или строки до разбора JSON, декодирования и преобразований middleware.
Проверка рекламы на риски: Что в макете вызовет вопросы ФАС: превосходные степени, сравнения, обещания результата, маркировка.
Спросить за 40 ₽Источники: Node.js Crypto API: HMAC и timingSafeEqual, RFC 2104: HMAC: Keyed-Hashing for Message Authentication
Частые вопросы
Можно ли проверять вебхук только по секретному заголовку?
Какой интервал выбрать для timestamp?
Что делать, если JSON уже разобран фреймворком?
Защитит ли HMAC от повторной доставки вебхука?
Что делать, если проверка работает в тесте, но ломается в проде?
Можно ли использовать пример с любым сервисом вебхуков?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.