Вебхуки живут под постоянным давлением: нестабильные сети, агрессивные прокси, кэширование, повторные доставки и попытки подмены. Без строгой валидации подписи (HMAC), защиты от повторов (replay) и аккуратной конфигурации обратного прокси вы быстро получите либо ложные сбои, либо дыру в безопасности. В этом гайде собрал практические паттерны, которые переживут CDN, Nginx, gzip и реальную эксплуатацию.
Модель угроз и цели
Сценарии атак на webhook:
- Подмена запроса: злоумышленник пытается отправить POST от имени провайдера без знания секрета.
- Replay: повтор легитимного запроса через минуты/часы для повторного воздействия на систему.
- Порча тела по пути: прокси декомпрессирует/модифицирует тело; валидация «на бэке» расходится с тем, что подписал провайдер.
- Несогласованность времени: рассинхрон часов рвёт окно допуска для защиты от повторов.
- Заголовки и канонизация: различия в кодировках подписи (hex/base64), регистре названий, мульти-значных полях.
Цель: приёмник должен детерминированно вычислять ту же строку для HMAC, что подписал отправитель, и дополнительно отсекать устаревшие и повторные запросы.
Дизайн подписи: HMAC и формат заголовков
Что именно подписывать
Типичный и устойчивый формат «сообщения» для HMAC:
- timestamp (эпоха в секундах или миллисекундах);
- HTTP метод (обычно POST);
- путь с query (например,
/webhooks/provider?topic=billing); - RAW body без каких-либо преобразований байт.
Соберите «базовую строку» объединением через \n (или другой фиксированный разделитель), где порядок полей жёстко описан в спецификации вашего вебхука. Пример конкатенации:
base = timestamp + "\n" + method + "\n" + path_and_query + "\n" + raw_body_bytes
Почему не включать хост и схему? За прокси хост часто меняется, а отправитель обычно не подписывает абсолютный URL. Канонизируйте только то, что гарантированно стабильно по пути до вашего приложения: метод, путь+query, сырое тело, время.
Алгоритм и представление подписи
- Алгоритм: HMAC-SHA256 хватает почти всегда; SHA-512 используют реже. Уточняйте у провайдера.
- Кодировка: часто hex (
64символа для SHA-256) или base64. Не «угадывайте», фиксируйте формат. - Заголовок: удобно в виде
X-Signature: v1=hex, t=unixили отдельные поляX-SignatureиX-Timestamp.
Если провайдер присылает несколько версий одновременно (например, v1 и v2 в одном заголовке), валидируйте по всем известным версиям. Это важно при миграциях.
Сравнение в константное время
Нельзя сравнивать подписи обычным == из-за тайминговых атак. Нужен «constant-time» компаратор хэш-байтов. В языках есть готовые функции: crypto.timingSafeEqual (Node.js), hash_equals (PHP), hmac.Equal (Go), hmac.compare_digest (Python).
Версионирование и ротация секретов
Храните минимум два секрета: текущий и предыдущий. Проверяйте подпись последовательно обоими в течение короткого переходного окна, потом удаляйте старый. Фиксируйте версию секрета или схемы в заголовке (v=1, key-id=abc), чтобы не перебирать лишнее.
Защита от повторов (replay)
Базовая стратегия включает два слоя:
- Time-window: по заголовку
X-Timestampотклонять запросы старше, например, 5 минут, с учётом допустимого сдвига часов. - Nonce/ID дедупликация: хранить одноразовый идентификатор запроса (или хэш из подписи+timestamp) в быстрой БД с TTL и отклонять повтор.
Если провайдер даёт уникальный event_id, используйте его как ключ дедупликации. Если нет — сформируйте ключ сами: replay:{timestamp}:{hex_signature_prefix} или хэш из комбинации timestamp+подпись. TTL ставьте больше окна времени (например, 10–15 минут).
Проверка времени без дедупликации не защищает от повторов внутри окна, а дедупликация без времени не спасает от «отложенного» воспроизведения.

Жизнь за прокси и CDN
RAW body: никаких преобразований
Подпись считается по исходным байтам. Любая модификация тела на прокси ломает валидацию:
- Декомутация gzip (
gunzip) прокси; - Перекодировка, нормализация JSON, изменение порядка полей;
- Склейка/расщепление chunked-кадров непринципиальна, если вы читаете именно байты после TCP; но опасна декомпрессия.
Для эндпоинта вебхука отключайте сжатие и декомпрессию, не меняйте Accept-Encoding произвольно, не трогайте тело. В приложении рассчитывайте HMAC по «сырым» байтам тела, а не по результату JSON-парсинга.
Пути, хосты и X-Forwarded-*
Не включайте хост в строку подписи, если провайдер его не подписывает. За прокси он может отличаться. Важно корректно восстанавливать реальный клиентский IP для логов/лимитов: X-Forwarded-For и real_ip в Nginx с явным set_real_ip_from только для доверенных адресов. Никогда не доверяйте X-Forwarded-For без настройки доверенных прокси.
Переадресация заголовков
Ваш прокси обязан прозрачно пропустить сигнатурные заголовки (X-Signature, X-Timestamp, Stripe-Signature и т. п.). Не переименовывать, не объединять, не фильтровать. Помните различие hop-by-hop и end-to-end заголовков — некоторые могут быть отброшены/заменены прокси.
Буферизация и таймауты
Включите буферизацию запроса, разумные таймауты и небольшие лимиты тела для снижения площади DoS-атак. Но следите, чтобы буферизация не вносила преобразований данных.
Для предсказуемости и полного контроля цепочки прокси удобно вынести приём вебхуков на отдельный инстанс. Собрать минимальный обратный прокси и приложение проще всего на собственном VDS.
Минимальный чек-лист валидации
- Лимит размера тела и правильный Content-Type.
- Чтение RAW body байтово; запрет автодекодирования gzip.
- Извлечение
timestampи подписи из заголовков; парсинг версий. - Проверка окна времени с учётом допустимого сдвига часов.
- Формирование базовой строки строго по спецификации.
- HMAC по секрету(ам); constant-time сравнение.
- Nonce/ID дедупликация в Redis/базе с TTL.
- Логирование результата и причин отказа без утечки секретов.
- Идемпотентная обработка в приложении (safe retry).
Примеры кода: верификация HMAC
Node.js (Express без автопарсинга)
const crypto = require('crypto');
// Не используйте bodyParser.json() на этом роуте. Читайте raw.
app.post('/webhooks/provider', express.raw({ type: '*/*' }), async (req, res) => {
const sigHeader = req.get('X-Signature') || '';
const tsHeader = req.get('X-Timestamp') || '';
const ts = parseInt(tsHeader, 10);
const now = Math.floor(Date.now() / 1000);
if (!Number.isFinite(ts) || Math.abs(now - ts) > 300) {
return res.status(400).send('stale');
}
const method = req.method;
const pathAndQuery = req.originalUrl; // путь + query
const base = ts + "\n" + method + "\n" + pathAndQuery + "\n";
const msg = Buffer.concat([Buffer.from(base, 'utf8'), req.body]);
const secrets = [process.env.WEBHOOK_SECRET_ACTIVE, process.env.WEBHOOK_SECRET_PREV].filter(Boolean);
const providedHex = sigHeader.replace(/^v1=/, '');
const provided = Buffer.from(providedHex, 'hex');
let ok = false;
for (const s of secrets) {
const h = crypto.createHmac('sha256', Buffer.from(s, 'utf8')).update(msg).digest();
if (h.length === provided.length && crypto.timingSafeEqual(h, provided)) {
ok = true; break;
}
}
if (!ok) return res.status(401).send('bad signature');
// Далее: дедупликация по nonce/event_id в Redis (не показано)
res.status(200).send('ok');
});
PHP (FPM)
<?php
$raw = file_get_contents('php://input');
$sig = $_SERVER['HTTP_X_SIGNATURE'] ?? '';
$ts = intval($_SERVER['HTTP_X_TIMESTAMP'] ?? '0');
$now = time();
if ($ts <= 0 || abs($now - $ts) > 300) {
http_response_code(400); exit('stale');
}
$method = $_SERVER['REQUEST_METHOD'] ?? 'POST';
$pathAndQuery = $_SERVER['REQUEST_URI'] ?? '/';
$base = $ts . "\n" . $method . "\n" . $pathAndQuery . "\n";
$msg = $base . $raw;
$provided = preg_replace('/^v1=/', '', $sig);
$providedBin = @hex2bin($provided);
$secrets = array_filter([getenv('WEBHOOK_SECRET_ACTIVE'), getenv('WEBHOOK_SECRET_PREV')]);
$ok = false;
foreach ($secrets as $s) {
$calc = hash_hmac('sha256', $msg, $s, true);
if ($providedBin !== false && hash_equals($calc, $providedBin)) { $ok = true; break; }
}
if (!$ok) { http_response_code(401); exit('bad signature'); }
// TODO: Redis SETNX nonce с TTL
http_response_code(200);
echo 'ok';
Go (net/http)
func webhookHandler(w http.ResponseWriter, r *http.Request) {
sig := r.Header.Get("X-Signature")
tsStr := r.Header.Get("X-Timestamp")
ts, _ := strconv.ParseInt(tsStr, 10, 64)
if ts == 0 || abs(time.Now().Unix()-ts) > 300 {
http.Error(w, "stale", http.StatusBadRequest)
return
}
body, _ := io.ReadAll(r.Body)
base := fmt.Sprintf("%d\n%s\n%s\n", ts, r.Method, r.URL.RequestURI())
msg := append([]byte(base), body...)
provided := strings.TrimPrefix(sig, "v1=")
providedBin, _ := hex.DecodeString(provided)
secrets := []string{os.Getenv("WEBHOOK_SECRET_ACTIVE"), os.Getenv("WEBHOOK_SECRET_PREV")}
ok := false
for _, s := range secrets {
if s == "" { continue }
mac := hmac.New(sha256.New, []byte(s))
mac.Write(msg)
sum := mac.Sum(nil)
if len(sum) == len(providedBin) && hmac.Equal(sum, providedBin) {
ok = true
break
}
}
if !ok { http.Error(w, "bad signature", http.StatusUnauthorized); return }
w.WriteHeader(http.StatusOK)
w.Write([]byte("ok"))
}
Redis: дедупликация nonce/ID
Идея проста: пытаемся атомарно создать ключ с TTL. Если не удалось — это повтор.
# Примерный алгоритм (псевдо):
key = "replay:" + eventIdOrHash
ok = SET key 1 NX EX 900
if not ok: reject as replay
Если провайдер не даёт event_id, можно использовать хэш из подписи и timestamp. Будьте аккуратны: не используйте весь body как часть ключа, иначе получите давление на память. Ограничьтесь коротким стабильным идентификатором.

Nginx: проксирование вебхуков без сюрпризов
# Доверяем реальный IP только своему балансеру/CDN
set_real_ip_from 203.0.113.0/24;
real_ip_header X-Forwarded-For;
real_ip_recursive on;
server {
listen 443 ssl;
server_name example.com;
# Ограничим размер тела
client_max_body_size 2m;
# Специальная локация для вебхука
location = /webhooks/provider {
# Запрещаем компрессию/декомпрессию для стабильности подписи
gzip off;
gunzip off;
proxy_request_buffering on;
proxy_buffering on;
# Пробрасываем все заголовки как есть
proxy_pass_request_headers on;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Request-Id $request_id;
# Метод должен быть POST — мягкая проверка на уровне прокси
limit_except POST { deny all; }
# Жёсткие таймауты
proxy_connect_timeout 3s;
proxy_send_timeout 10s;
proxy_read_timeout 10s;
# Направление в приложение
proxy_pass http://app_backend;
}
}
Не используйте gunzip для этой локации — сломает RAW body. Если CDN сжимает тело, убедитесь, что отправитель подписывает уже сжатое представление (обычно вебхуки идут без сжатия). Явно задайте доверенные IP для set_real_ip_from, иначе подделанные X-Forwarded-For попадут в логи и лимиты.
Приём вебхуков должен идти по HTTPS с корректной цепочкой доверия. Оформите и подключите валидные SSL-сертификаты и проверяйте их срок действия.
Согласованность времени
Окно времени для защиты от replay работает только при синхронных часах. Держите NTP-клиент и мониторьте дрейф. Разумный допуск — 5 минут, но под высокую нагрузку и возможные очереди в сети можно поднять до 10 минут, увеличив и TTL в дедупликации.
Тестирование: как синтетически сгенерировать подпись
Пример генерации hex-HMAC-SHA256 через OpenSSL и отправки в локальный стенд:
SECRET="test_secret"
TS=$(date +%s)
METHOD="POST"
PATH="/webhooks/provider?topic=billing"
BODY='{"ok":true}'
BASE="$TS\n$METHOD\n$PATH\n$BODY"
SIG=$(printf "%s" "$BASE" | openssl dgst -sha256 -hmac "$SECRET" -binary | xxd -p -c 256)
curl -i -H "Content-Type: application/json" -H "X-Timestamp: $TS" -H "X-Signature: v1=$SIG" --data "$BODY" http://127.0.0.1/webhooks/provider?topic=billing
Важно: BASE собирается точно так же, как на сервере. Любой лишний перевод строки или пробел меняет HMAC. Тело передавайте без автопереформатирования.
Логирование и метрики
- Логируйте
request_id, таймстамп, причину отказа (stale, bad_signature, replay) без дампа секрета и полной подписи. - Счётчики отказов по причинам и провайдерам; алерты на всплески.
- Гистограммы времени валидации и размера тел для поиска аномалий.
Ротация секретов и аварийные сценарии
Практика:
- Держите активный и предыдущий секреты; плановый срок жизни фиксирован.
- При инциденте — немедленно перевыпустить секрет у провайдера, добавить как активный; предыдущий оставить валидным на краткое окно (если нужно для догона очереди), затем удалить.
- Версионируйте заголовком:
key-idилиvпомогут быстро переключаться.
Частые ошибки и как их избежать
- Подпись по JSON после парсинга — нельзя, только по RAW body.
- Сравнение строкой с
==— используйте constant-time. - Отсутствует проверка времени — откроете окно для бесконечных повторов.
- Нет дедупликации — получаете повторную обработку внутри окна.
- Включён
gunzipв Nginx — подписи не сходятся. - Доверяете
X-Forwarded-Forбезset_real_ip_from— некорректные IP и правила. - Меняете путь на прокси (переписываете
rewrite) без учёта, что путь подписан — валидация сломается. - Принимаете GET — поставщики обычно шлют POST; отклоняйте прочие методы.
Идемпотентность обработчика
Даже с защитой от replay полезно делать обработчик идемпотентным: хранить статус обработанного события по его event_id и безопасно возвращать 200 при повторе. Это снижает нагрузку и шум в логах.
Итоги
Надёжная валидация webhook — это сочетание корректного HMAC по строго определённой базовой строке, константного сравнения, защиты от повторов временем и nonce, плюс грамотная прокси-конфигурация без преобразования тела. Добавьте ротацию секретов, мониторинг, идемпотентность — и ваши интеграции переживут и CDN, и сетевые турбулентности без ложных срабатываний и утечек. Для приёма в продакшене используйте HTTPS с валидными SSL-сертификаты и, при необходимости, выделенный инстанс на VDS.


