HMAC How Webhooks Know You’re Not Lying

Опубликовано

Каждый раз, когда GitHub, Stripe или Shopify отправляют webhook на ваш сервер, они подписывают загрузку с помощью HMAC. Вот как это работает — и как проверить это в своём коде.

HMAC: How Webhooks Know You're Not Lying 1
Реклама · УДАЛИТЬ?

You receive a POST request claiming to be from Stripe. The payload says a payment just succeeded. You process the order, ship the goods — and three days later you discover the request was faked. Ouch.

This is why every serious webhook provider signs their payloads using HMAC. If you understand HMAC, you understand why GitHub, Stripe, Shopify, Twilio, and practically every modern API uses it — and you’ll know exactly how to verify those signatures in your own server code.

What HMAC actually is

HMAC stands for Hash-based Message Authentication Code. It answers one question: “Did the person who sent me this message know the shared secret?”

It works by running a cryptographic hash function — usually SHA-256 — over the combination of a secret key and the message body. The output is a fixed-length string that:

  • Changes completely if even one byte of the message changes
  • Cannot be produced without knowing the secret key
  • Cannot be reversed to reveal the key or the original message

The formula is compact: HMAC(key, message) = H((key ⊕ opad) || H((key ⊕ ipad) || message)). You don’t need to memorise the internals — every language ships a standard library implementation — but it helps to know the intent: the key is mixed into the hash twice, in different ways, to prevent a class of attacks called length-extension attacks.

How a webhook provider uses it

When you register a webhook endpoint, the provider gives you a signing secret — a random string only you and they know. When an event fires:

  1. The provider serialises the event payload to JSON (or a canonical string).
  2. It computes HMAC-SHA256(secret, payload).
  3. It sends the request with the signature in a header — X-Hub-Signature-256 for GitHub, Stripe-Signature for Stripe, and so on.

On your end, you do the same computation over the raw request body and compare. If they match, the payload is authentic. If they don’t, discard it.

Verifying in code

Here’s what verification looks like across the most common languages. The pattern is identical in every one: compute, compare with a constant-time function.

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);
}

The critical detail: use the raw body

The most common implementation mistake is passing a parsed and re-serialised payload to the HMAC function instead of the original raw bytes. Key ordering, whitespace, and Unicode escaping all affect the hash. The provider signed the exact bytes it sent over the wire — you must hash exactly those same bytes.

In Express (Node.js), that means configuring the body parser to preserve the raw buffer:

app.use('/webhooks', express.raw({ type: 'application/json' }));

In Django, use request.body rather than request.data. In Flask, use request.get_data().

Why not just a plain hash?

A plain SHA-256 of the payload proves nothing — anyone can compute SHA256(payload) without a secret. HMAC’s secret key is what makes it an аутентификация code, not just a checksum. It answers “who sent this” rather than just “was this corrupted in transit”.

Why not asymmetric signatures (like RSA)?

RSA and ECDSA let the receiver verify a signature without knowing the private key — that’s valuable for public broadcast (like code-signing). For webhooks, there are only two parties who ever need to verify the signature: you and the provider. A shared secret is simpler, faster, and equally secure in that model. Some providers (Svix, Clerk) do offer asymmetric webhook signing for cases where you can’t safely store a secret server-side.

Replay attacks — and how to stop them

A valid HMAC signature proves authenticity but not freshness. An attacker who captures a legitimate signed request can replay it later. Stripe counters this by including a timestamp in the Stripe-Signature header and hashing the timestamp alongside the payload body. On your side, you reject any request where the timestamp is more than five minutes old.

If you’re building your own webhook system, do the same: include a monotonically increasing nonce or a Unix timestamp in the signed message, and reject stale requests server-side.

Timing-safe comparison is not optional

Never compare HMAC signatures with a plain equality check (===, ==). Short-circuit string comparison leaks information about how many leading bytes match — an attacker making thousands of requests can reconstruct the expected signature one byte at a time. Always use a constant-time comparison:

  • Node.js: crypto.timingSafeEqual()
  • Python: hmac.compare_digest()
  • PHP: hash_equals()
  • Go: hmac.Equal()
  • Ruby: ActiveSupport::SecurityUtils.secure_compare()

Putting it together: a production-ready webhook handler

Here’s a complete example using GitHub’s X-Hub-Signature-256 header in 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);

Quick reference: who uses what

ProviderЗаголовокАлгоритмTimestamp in signature?
GitHubX-Hub-Signature-256HMAC-SHA256Нет
StripeStripe-SignatureHMAC-SHA256Да
ShopifyX-Shopify-Hmac-Sha256HMAC-SHA256Нет
TwilioX-Twilio-SignatureHMAC-SHA1Нет
СлакX-Slack-SignatureHMAC-SHA256Да
PaddlePaddle-SignatureHMAC-SHA256Да
Хотите убрать рекламу? Откажитесь от рекламы сегодня

Установите наши расширения

Добавьте инструменты ввода-вывода в свой любимый браузер для мгновенного доступа и более быстрого поиска

в Расширение Chrome в Расширение края в Расширение Firefox в Расширение Opera

Табло результатов прибыло!

Табло результатов — это интересный способ следить за вашими играми, все данные хранятся в вашем браузере. Скоро появятся новые функции!

Реклама · УДАЛИТЬ?
Реклама · УДАЛИТЬ?
Реклама · УДАЛИТЬ?

новости с техническими моментами

Примите участие

Помогите нам продолжать предоставлять ценные бесплатные инструменты

Купи мне кофе
Реклама · УДАЛИТЬ?