HMAC Как вебхуки узнают, что вы не лжёте
Каждый раз, когда GitHub, Stripe или Shopify отправляют webhook на ваш сервер, они подписывают загрузку с помощью HMAC. Вот как это работает — и как проверить это в своём коде.
Вы получаете POST-запрос, утверждающий, что он идёт от Stripe. В теле запроса указано, что оплата была успешно завершена. Вы обрабатываете заказ, отправляете товары — и через три дня обнаруживаете, что запрос был поддельным. Оuch.
Именно поэтому каждый серьёзный поставщик вебхуков подписывает свои тела запросов с помощью HMAC. Если вы понимаете HMAC, вы понимаете, почему GitHub, Stripe, Shopify, Twilio и почти все современные API используют его — и вы точно знаете, как проверять эти подписи в собственном серверном коде.
Что такое HMAC на самом деле
HMAC означает Код аутентификации сообщения на основе хеша. Он отвечает на один вопрос: «Знал ли отправитель этого сообщения общий секрет?»
Он работает так: хеш-функция — обычно SHA-256 — применяется к комбинации секретного ключа и тела сообщения. Результат — это строка фиксированной длины, которая:
- полностью меняется если даже один байт в теле сообщения изменён
- не может быть сгенерирована без знания секретного ключа
- не может быть отменена чтобы раскрыть ключ или исходное сообщение
Формула компактна: HMAC(key, message) = H((key ⊕ opad) || H((key ⊕ ipad) || message)). Вам не нужно запоминать внутренности — в каждой языковой среде есть стандартная реализация — но полезно понимать намерение: ключ смешивается в хеше дважды, по-разному, чтобы предотвратить класс атак, называемых атаками расширения длины.
Как поставщик вебхуков использует это
Когда вы регистрируете конечную точку вебхука, поставщик предоставляет вам секрет подписи — случайную строку, которую знают только вы и он. Когда происходит событие:
- Поставщик сериализует тело события в формате JSON (или в канонической строке).
- Он вычисляет
HMAC-SHA256(secret, payload). - Он отправляет запрос с подписью в заголовке —
X-Hub-Signature-256для GitHub,Stripe-Signatureдля Stripe и так далее.
На вашей стороне вы выполняете то же вычисление над исходным телом запроса и сравниваете результаты. Если они совпадают, тело запроса является подлинным. Если нет — отклоняйте его.
Проверка в коде
Вот как выглядит проверка в наиболее распространённых языках. Шаблон одинаков во всех: вычисляется, сравнивается с функцией постоянного времени.
Node.js
const crypto = require('crypto');
function verifyWebhook(rawBody, signature, secret) {
const expected = crypto
.createHmac('sha256', secret)
.update(rawBody) // rawBody must be a Buffer or string — NOT parsed JSON
.digest('hex');
return crypto.timingSafeEqual(
Buffer.from(expected),
Buffer.from(signature)
);
}
Питон
import hmac
import hashlib
def verify_webhook(raw_body: bytes, signature: str, secret: str) -> bool:
expected = hmac.new(
secret.encode(),
raw_body,
hashlib.sha256
).hexdigest()
return hmac.compare_digest(expected, signature)
PHP
function verifyWebhook(string $rawBody, string $signature, string $secret): bool {
$expected = hash_hmac('sha256', $rawBody, $secret);
return hash_equals($expected, $signature);
}
Критическая деталь: использовать исходный тело
Наиболее распространённая ошибка при реализации — передача парсированного и пересериализованного тела запроса в функцию HMAC вместо исходных байтов. Порядок ключей, пробелы и упаковка Unicode влияют на хеш. Поставщик подписал именно те байты, которые отправил по сети — вы должны хэшировать ровно те же самые байты.
В Express (Node.js) это означает настройку парсера тела запроса для сохранения исходного буфера:
app.use('/webhooks', express.raw({ type: 'application/json' }));
В Django используйте request.body а не request.data. В Flask используйте request.get_data().
Почему не просто хеш?
Простой SHA-256 хеш тела не доказывает ничего — любой может вычислить SHA256(payload) без секрета. Секретный ключ HMAC делает его аутентификация кодом, а не просто контрольной суммой. Он отвечает на вопрос «от кого пришло это сообщение», а не только на вопрос «было ли сообщение повреждено в процессе передачи».
Почему не асимметричные подписи (например, RSA)?
RSA и ECDSA позволяют получателю проверять подпись без знания приватного ключа — это ценное свойство для открытого распространения (например, подпись кода). Для вебхуков есть только две стороны, которые когда-либо проверяют подпись: вы и поставщик. Общий секрет проще, быстрее и в этом сценарии так же безопасен. Некоторые поставщики (Svix, Clerk) предлагают асимметричную подпись вебхуков в тех случаях, когда невозможно безопасно хранить секрет на сервере.
Атаки переподписи — и как их предотвратить
Подлинная подпись HMAC подтверждает подлинность, но не свежесть. Атакующий, который захватывает законный подписанный запрос, может его переподписать позже. Stripe противодействует этому, включая временные метки в заголовке Stripe-Signature и хешируя временные метки вместе с телом запроса. На вашей стороне вы отклоняете любой запрос, где временная метка старше пяти минут.
Если вы создаёте собственную систему вебхуков, делайте то же самое: включайте монотонно увеличивающийся nonce или Unix-временную метку в подписанное сообщение и отклоняйте устаревшие запросы на сервере.
Проверка на равенство не является необязательной
Никогда не сравнивайте подписи HMAC с простым равенством (===, ==). Сравнение строк с коротким сокращением раскрывает информацию о том, сколько первых байтов совпадают — атакующий, делающий тысячи запросов, может восстановить ожидаемую подпись по одному байту за раз. Всегда используйте сравнение с постоянным временем:
- Node.js:
crypto.timingSafeEqual() - Python:
hmac.compare_digest() - PHP:
hash_equals() - Go:
hmac.Equal() - Ruby:
ActiveSupport::SecurityUtils.secure_compare()
Сборка: готовый обработчик вебхука
Вот полный пример с использованием заголовка GitHub X-Hub-Signature-256 заголовка в Node.js:
const express = require('express');
const crypto = require('crypto');
const app = express();
const WEBHOOK_SECRET = process.env.GITHUB_WEBHOOK_SECRET;
// Keep the body as raw bytes — critical!
app.use('/github/webhook', express.raw({ type: 'application/json' }));
app.post('/github/webhook', (req, res) => {
const sigHeader = req.headers['x-hub-signature-256'];
if (!sigHeader) return res.status(401).send('Missing signature');
const sig = sigHeader.replace('sha256=', '');
const expected = crypto
.createHmac('sha256', WEBHOOK_SECRET)
.update(req.body)
.digest('hex');
if (!crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(sig))) {
return res.status(401).send('Invalid signature');
}
const event = JSON.parse(req.body);
// Safe to process the event now
console.log('Event type:', req.headers['x-github-event']);
res.sendStatus(200);
});
app.listen(3000);
Быстрый справочник: кто использует что
| Поставщик | Заголовок | Алгоритм | Временная метка в подписи? |
|---|---|---|---|
| GitHub | X-Hub-Signature-256 | HMAC-SHA256 | Нет |
| Stripe | Stripe-Signature | HMAC-SHA256 | Да |
| Shopify | X-Shopify-Hmac-Sha256 | HMAC-SHA256 | Нет |
| Twilio | X-Twilio-Signature | HMAC-SHA1 | Нет |
| Слак | X-Slack-Signature | HMAC-SHA256 | Да |
| Paddle | Paddle-Signature | HMAC-SHA256 | Да |
Вам также может понравиться
Установите наши расширения
Добавьте инструменты ввода-вывода в свой любимый браузер для мгновенного доступа и более быстрого поиска
恵 Табло результатов прибыло!
Табло результатов — это интересный способ следить за вашими играми, все данные хранятся в вашем браузере. Скоро появятся новые функции!
Подписаться на новости
все Новые поступления
всеОбновлять: Наш последний инструмент был добавлен 17 мая 2026 года
