Більшість збоїв перевірки підпису вебхуків — не крипто-баги. Це баги парсингу. Хтось розпарсив тіло до перевірки. Хтось використав 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 для тунелю + захоплення, що роблять це налагоджуваним.