DEV Community

Gerald Mitchell
Gerald Mitchell

Posted on

GitHub webhook signature mismatch? 5 reasons X-Hub-Signature-256 won't verify (Node and Python fixes)

Disclosure: I run webhook-relay.gmitchell-relay.workers.dev, a free site with webhook error fix pages. This article was written with AI assistance and reviewed before publishing.

You set up a GitHub webhook, add a secret, write ten lines of HMAC code, and every delivery comes back 401 signature mismatch. The code usually looks right. Something about the inputs is wrong. Here's how GitHub signs deliveries and the five things that most often break verification.

How GitHub signs a delivery

For every delivery, GitHub computes an HMAC-SHA256 over the raw request body, keyed with your webhook secret. It sends the result hex-encoded with a prefix:

X-Hub-Signature-256: sha256=3f5a...e9
Enter fullscreen mode Exit fullscreen mode

To verify, you compute the same HMAC over the exact bytes you received and compare it to the header in constant time. Any byte difference in the body, the secret, or the encoding produces a mismatch.

1. No secret is configured, so there's no header

If the webhook in Settings → Webhooks has no secret, GitHub doesn't send X-Hub-Signature-256 at all. Your code ends up comparing against undefined or an empty string.

Fix: set a secret on the webhook, and make your handler reject requests with a missing header explicitly. A missing header should get a clear 401, not a crash.

2. Wrong header or wrong algorithm

X-Hub-Signature (no -256) is the legacy SHA-1 signature. If you read that header but compute SHA-256, or the other way round, nothing will ever match. Also remember the value starts with sha256=. Compare against "sha256=" + hexDigest, or strip the prefix first.

3. You hashed parsed JSON, not the raw body

This is the most common cause. If a body parser (express.json(), a framework default) already turned the body into an object, JSON.stringify(req.body) won't reproduce GitHub's bytes. Whitespace, key order and unicode escaping all differ.

Fix: hash the raw bytes. In Express, use express.raw() on the webhook route only. In Flask, use request.get_data().

4. The webhook's content type is form-encoded

If the webhook's Content type is application/x-www-form-urlencoded, GitHub signs the whole form body (payload=%7B%22...), not the decoded JSON inside it. Code that extracts payload and hashes it will fail.

Fix: switch the webhook to application/json, or hash the raw form body exactly as received.

5. The secret isn't the one you think

Common variations:

  • a trailing newline or space in the env var (very common when the value came from echo or a .env file)
  • a different secret per webhook, so the repo webhook and the org webhook don't share one
  • a GitHub App's webhook secret vs a repository webhook's secret

Fix: if in doubt, set a fresh secret on the webhook and in your env, then redeploy.

Working code: Node.js (Express)

const express = require('express');
const crypto = require('crypto');

const app = express();
const SECRET = process.env.GITHUB_WEBHOOK_SECRET;

// Raw body on the webhook route only, registered before any global express.json()
app.post('/github/webhook', express.raw({ type: 'application/json' }), (req, res) => {
  const header = req.get('X-Hub-Signature-256');
  if (!header) return res.status(401).send('missing signature');

  const expected = 'sha256=' +
    crypto.createHmac('sha256', SECRET).update(req.body).digest('hex');

  const a = Buffer.from(header);
  const b = Buffer.from(expected);
  // timingSafeEqual throws if lengths differ, so check first
  if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) {
    return res.status(401).send('signature mismatch');
  }

  const event = req.get('X-GitHub-Event');
  const payload = JSON.parse(req.body.toString('utf8'));
  // ... handle event ...
  res.sendStatus(204);
});

app.use(express.json()); // everything else

app.listen(3000);
Enter fullscreen mode Exit fullscreen mode

Check that req.body is a Buffer here. If it's an object, a parser already ran and the raw bytes are gone.

Working code: Python (Flask)

import hashlib
import hmac
import os

from flask import Flask, abort, request

app = Flask(__name__)
SECRET = os.environ["GITHUB_WEBHOOK_SECRET"].encode()

@app.post("/github/webhook")
def github_webhook():
    header = request.headers.get("X-Hub-Signature-256", "")
    body = request.get_data()  # raw bytes; call before request.json
    expected = "sha256=" + hmac.new(SECRET, body, hashlib.sha256).hexdigest()
    if not header or not hmac.compare_digest(header, expected):
        abort(401)
    event = request.headers.get("X-GitHub-Event")
    payload = request.get_json()
    # ... handle event ...
    return "", 204
Enter fullscreen mode Exit fullscreen mode

Debugging tips

  • In Settings → Webhooks → Recent Deliveries, you can see the exact headers and payload GitHub sent, and Redeliver a delivery after each fix instead of waiting for a new event.
  • Temporarily log the raw body length, the received header, and the first few characters of your computed digest. Don't log the secret. If the body length differs from what GitHub shows, something between GitHub and your code changed the bytes (a proxy, a body parser, or re-encoding).
  • If you're behind a reverse proxy or tunnel, make sure it passes the body through unmodified.

Summary

Symptom Likely cause
Header missing No secret set on the webhook
Never matches, even with a correct secret Hashing parsed JSON, wrong algorithm, or missing sha256= prefix
payload= in the body Form-encoded content type
Worked before a redeploy Secret changed, or whitespace in the env var

The full checklist, kept up to date, is on our free page: GitHub webhook signature mismatch.

Top comments (0)