ZIM-НИЙ SAAALEЗимние скидки: до −50% на старт и −20% на продление
до 31.01.2026 Подробнее
Выберите продукт

Подписи вебхуков: HMAC, защита от повторов и правильная валидация за прокси

Подпись webhook через HMAC — идея простая, но её часто ломают прокси, gzip и неверная логика времени/nonce. В статье — безопасный формат заголовков, анти‑replay, ротация секретов и конфигурация Nginx, чтобы валидация была стабильной и предсказуемой, плюс практические примеры.
Подписи вебхуков: HMAC, защита от повторов и правильная валидация за прокси

Вебхуки живут под постоянным давлением: нестабильные сети, агрессивные прокси, кэширование, повторные доставки и попытки подмены. Без строгой валидации подписи (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)

Базовая стратегия включает два слоя:

  1. Time-window: по заголовку X-Timestamp отклонять запросы старше, например, 5 минут, с учётом допустимого сдвига часов.
  2. Nonce/ID дедупликация: хранить одноразовый идентификатор запроса (или хэш из подписи+timestamp) в быстрой БД с TTL и отклонять повтор.

Если провайдер даёт уникальный event_id, используйте его как ключ дедупликации. Если нет — сформируйте ключ сами: replay:{timestamp}:{hex_signature_prefix} или хэш из комбинации timestamp+подпись. TTL ставьте больше окна времени (например, 10–15 минут).

Проверка времени без дедупликации не защищает от повторов внутри окна, а дедупликация без времени не спасает от «отложенного» воспроизведения.

Фрагмент конфигурации Nginx для безопасного проксирования вебхуков

Жизнь за прокси и 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.

FastFox VDS
Облачный VDS-сервер в России
Аренда виртуальных серверов с моментальным развертыванием инфраструктуры от 195₽ / мес

Минимальный чек-лист валидации

  • Лимит размера тела и правильный 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 как часть ключа, иначе получите давление на память. Ограничьтесь коротким стабильным идентификатором.

Схема дедупликации nonce в Redis для защиты от повторов вебхуков

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-сертификаты и проверяйте их срок действия.

FastFox SSL
Надежные SSL-сертификаты
Мы предлагаем широкий спектр SSL-сертификатов от GlobalSign по самым низким ценам. Поможем с покупкой и установкой 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.

Поделиться статьей

Вам будет интересно

MySQL: EXPLAIN ANALYZE и optimizer_trace — читаем план, считаем время, находим узкие места OpenAI Статья написана AI (GPT 5)

MySQL: EXPLAIN ANALYZE и optimizer_trace — читаем план, считаем время, находим узкие места

Разберём диагностику медленных запросов в MySQL 8.0 с помощью EXPLAIN ANALYZE и optimizer_trace: где найти узел, который съел врем ...
Canary-выкатка и ротация PEM Let’s Encrypt без простоя в Nginx и Apache OpenAI Статья написана AI (GPT 5)

Canary-выкатка и ротация PEM Let’s Encrypt без простоя в Nginx и Apache

Пошаговый план обновления PEM-сертификатов Let’s Encrypt без обрывов: атомарная замена через симлинки или mv, canary-выкатка на од ...
IPv6 ACL ::/0 для reverse proxy: как не открыть админку всему миру OpenAI Статья написана AI (GPT 5)

IPv6 ACL ::/0 для reverse proxy: как не открыть админку всему миру

IPv6 нередко включён по умолчанию, а доступ к админке ограничивают только для IPv4. В режиме dual stack это превращается в «дыру»: ...