Все статьи
webhook securityHMACsignature verificationbest practices

Проверка подписи вебхуков без загадок

Большинство сбоев проверки подписи вебхуков — не крипто-баги. Это баги парсинга. Кто-то распарсил тело до проверки. Кто-то использовал hex там, где провайдер хотел base64. Кто-то сравнил через ===, где нужно было timing-safe сравнение. Это руководство — краткая версия каждой проблемы с подписью, что мы когда-либо отлаживали.

Модель, которую используют провайдеры

Каждый крупный провайдер вебхуков — Stripe, GitHub, Shopify, Twilio, Slack, Discord — делает по сути одно и то же в разных обёртках:

  1. Вы и провайдер делите секрет.
  2. Провайдер вычисляет подпись по телу запроса (иногда плюс timestamp, иногда плюс URL).
  3. Подпись передаётся в заголовке.
  4. Ваш обработчик пересчитывает подпись с тем же секретом и отклоняет запрос, если они не совпадают.

Что меняется у провайдеров: алгоритм (чаще всего HMAC SHA-256; Discord использует Ed25519), кодировка (hex или base64), что именно подписывается (тело, тело+timestamp, URL+тело+параметры) и какой заголовок несёт подпись.

Четыре бага, объясняющие почти всё

1. Парсинг тела первым

В большинстве фреймворков есть JSON-мидлвар, парсящий входящие тела в объекты. Как только он отработал, сырые байты потеряны, и любая вычисленная вами подпись не совпадёт — даже если заново сериализовать распарсенный объект, пробелы и порядок ключей могут отличаться.

Решение: читайте сырое тело до любого мидлвара на маршруте вебхука. В Express монтируйте express.raw({ type: 'application/json' }) только на путь вебхука. В Next.js App Router читайте await request.text() напрямую. В Django используйте @csrf_exempt + request.body до запуска любого парсера DRF.

2. Неверная кодировка

Stripe и GitHub используют hex. Shopify использует base64. Один и тот же секрет даёт по-разному выглядящий вывод в каждом. Мы уже разбирали это в руководстве по Shopify — на этом спотыкаются все.

3. Не timing-safe

Сравнение строк (===) завершается рано на первом несовпавшем символе. Разница во времени между «совпал первый символ» и «совпали все 64 символа» утекает информацию о секрете. Используйте timing-safe сравнение:

// Node
crypto.timingSafeEqual(Buffer.from(a), Buffer.from(b));

// Python
hmac.compare_digest(a, b)

// Ruby
ActiveSupport::SecurityUtils.secure_compare(a, b)

На практике атаку трудно реализовать против вебхук-эндпоинта через публичный интернет, но безопасное сравнение настолько дёшево, что нет причин его не использовать.

4. Timestamp не проверен

Если подпись покрывает только тело, злоумышленник, перехвативший валидный запрос, может повторять его бесконечно. Провайдеры решают это, включая timestamp в payload подписи и указывая окно допуска в документации (Stripe: 5 минут, GitHub: тоже короткое). Ваш обработчик должен отклонять любую подпись с timestamp старше допуска.

Пропустите этот шаг — и вы построили систему, уязвимую к replay, как бы идеально ни работал HMAC.

Что на самом деле в заголовке

Заголовки провайдеров несут больше, чем просто «подпись». Stripe шлёт t=1234567890,v1=abcdef...,v0=... — timestamp плюс несколько версий подписи. GitHub шлёт sha256=abcdef... с префиксом. Twilio включает URL запроса в payload подписи, но шлёт саму подпись только в X-Twilio-Signature. Читайте документацию провайдера, с которым интегрируетесь, и реализуйте под точный формат.

Локальное тестирование делает это отлаживаемым

Проверка подписи — худший вид бага: тихий и труднопроизводимый на staging. Локальное тестирование с туннелем и захватом запросов даёт точный payload, который упал, точный заголовок, который пришёл, и кнопку повтора, чтобы перепроверить фикс, не беспокоя провайдера.

Когда проверка что-то отклоняет, логируйте достаточно для отладки: длину сырого тела, прочитанный заголовок, вычисленную подпись. Не логируйте секрет. (Мы видели, как люди логируют секреты. Не надо.)

Минимальный корректный верификатор

// Express + Node, generic HMAC SHA-256 hex provider
app.post(
  '/webhooks/foo',
  express.raw({ type: 'application/json' }),
  (req, res) => {
    const signature = req.headers['x-foo-signature'];
    const timestamp = req.headers['x-foo-timestamp'];

    // 1. Reject old timestamps (5 min tolerance)
    if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) {
      return res.status(400).send('stale');
    }

    // 2. Compute expected signature
    const expected = crypto
      .createHmac('sha256', process.env.FOO_SECRET)
      .update(`${timestamp}.${req.body}`)
      .digest('hex');

    // 3. Timing-safe compare
    if (!crypto.timingSafeEqual(
      Buffer.from(expected),
      Buffer.from(signature),
    )) {
      return res.status(401).send('invalid');
    }

    // 4. Now safe to parse and process
    const event = JSON.parse(req.body);
    handleEvent(event);
    res.status(200).end();
  },
);

Когда проверка подписи всё равно падает

Если всё выше верно, а оно всё равно падает, проверьте ещё три вещи: у секрета завершающие пробелы из env-файла, ваш туннель переписал заголовок (большинство не делают, но с захватом запросов стоит подтвердить), или вы тестируете против live-секрета, пока дашборд показывает тестовый. Мы ловили все три.

Специфику провайдеров смотрите в руководствах по Stripe, GitHub и Shopify. А если вы прямо сейчас смотрите на 401, переходите к отладке ошибок 401 вебхука. Присоединяйтесь к листу ожидания PortPreview для туннеля и захвата, которые делают это отлаживаемым.

Часто задаваемые вопросы

Почему падает проверка подписи моего вебхука?
Чаще всего тело было распарсено мидлваром до запуска проверки. Другие частые причины: несовпадение hex и base64, не-timing-safe сравнение, падающее в некоторых краевых случаях, или неверный секрет (test vs live, dashboard vs CLI).
Действительно ли нужно timing-safe сравнение для подписей вебхуков?
Да. Обычное сравнение строк утекает информацию о времени по байту секрета. Атаку трудно реализовать на практике, но безопасное сравнение — одна строка кода, так что нет причин его не использовать.
Почему моя подпись вебхука включает timestamp?
Без timestamp злоумышленник, перехвативший валидный подписанный запрос, может повторять его бесконечно. Timestamp позволяет обработчику отклонять всё старше окна допуска провайдера, обычно несколько минут.