Большинство сбоев проверки подписи вебхуков — не крипто-баги. Это баги парсинга. Кто-то распарсил тело до проверки. Кто-то использовал hex там, где провайдер хотел base64. Кто-то сравнил через ===, где нужно было timing-safe сравнение. Это руководство — краткая версия каждой проблемы с подписью, что мы когда-либо отлаживали.
Модель, которую используют провайдеры
Каждый крупный провайдер вебхуков — Stripe, GitHub, Shopify, Twilio, Slack, Discord — делает по сути одно и то же в разных обёртках:
- Вы и провайдер делите секрет.
- Провайдер вычисляет подпись по телу запроса (иногда плюс timestamp, иногда плюс URL).
- Подпись передаётся в заголовке.
- Ваш обработчик пересчитывает подпись с тем же секретом и отклоняет запрос, если они не совпадают.
Что меняется у провайдеров: алгоритм (чаще всего 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 для туннеля и захвата, которые делают это отлаживаемым.