48-hour launch: 40% off with code LAUNCH40, Guide $5.40 / Kit $11.40

GitHub webhook signature mismatch: fixing X-Hub-Signature-256 verification

GitHub signs each delivery with HMAC-SHA256 over the raw payload, using your webhook secret. It sends the result as sha256=<hex> in the X-Hub-Signature-256 header. When your computed value doesn't match, the cause is usually one of these:

  1. No secret configured. GitHub leaves out X-Hub-Signature-256 if the webhook has no secret, so your code is comparing against nothing.
  2. Wrong header or algorithm. X-Hub-Signature is legacy HMAC-SHA1. Use the -256 header with SHA-256, and keep the sha256= prefix in mind.
  3. Parsed body. Hashing JSON.stringify(req.body) won't reproduce GitHub's bytes. Hash the raw body.
  4. Form content type. If the webhook's content type is application/x-www-form-urlencoded, the signed bytes are the whole form body (payload=...), not the decoded JSON. Switch the webhook to application/json, or hash the raw form body.
  5. Wrong secret. Watch for whitespace or newlines in the env var, a different secret per webhook, or the GitHub App's secret vs a repository webhook's secret. If you're unsure, set a new secret on the webhook.
  6. Something changed the payload. A proxy or load balancer rewrote the body or headers, or the body was decoded with a non-UTF-8 charset. Payloads can contain Unicode.

Node (Express)

app.post('/github', express.raw({ type: '*/*' }), (req, res) => {
  const expected = 'sha256=' + crypto.createHmac('sha256', process.env.GITHUB_WEBHOOK_SECRET.trim())
    .update(req.body).digest('hex');
  const got = req.get('X-Hub-Signature-256') || '';
  const ok = got.length === expected.length && crypto.timingSafeEqual(Buffer.from(got), Buffer.from(expected));
  if (!ok) return res.sendStatus(401);
  res.sendStatus(202);   // then process asynchronously
});

Python (Flask)

expected = 'sha256=' + hmac.new(secret.encode(), request.get_data(), hashlib.sha256).hexdigest()
if not hmac.compare_digest(expected, request.headers.get('X-Hub-Signature-256', '')):
    abort(401)

The guide is Stripe-only. The kit is a self-hosted Cloudflare Worker relay that adds retries, replay and a dead-letter list in front of your endpoint. GitHub doesn't redeliver failed webhooks automatically, so retries matter here. It verifies X-Hub-Signature-256 out of the box. Set SCHEME_<ID>=github. It works the same way for Stripe, Lemon Squeezy, Shopify, Paddle Billing, Square and Twilio (status callbacks).

Fix Your Stripe Webhooks guide · Self-Hosted Webhook Relay Kit. Code LAUNCH40 = 40% off until Sun Oct 11, 6:40 AM MT.

Also: timeouts and redelivery

GitHub expects a 2xx within 10 seconds on github.com, otherwise it marks the delivery "timed out". It doesn't redeliver failed deliveries automatically. Use Recent deliveries in the webhook settings (or the REST API) to redeliver. A redelivery keeps the same X-GitHub-Delivery ID, so de-duplicate on it.

Free download: Webhook debugging cheat sheet.

Fighting webhooks on more than one platform? The Fix Your Stripe Webhooks guide ($9) covers raw-body and signature problems in depth, and the Self-Hosted Webhook Relay Kit ($19) adds retries, replay and a dead-letter list in front of any endpoint.