GitHub webhook signature verification failed: X-Hub-Signature-256 does not match
When your computed sha256=... value never equals the X-Hub-Signature-256 header, GitHub's own troubleshooting list names the usual causes: no secret configured, the wrong header or algorithm, the wrong secret, a payload or headers modified before verification, and a character-encoding slip. This page turns that list into a fast order of checks. It follows docs.github.com, Validating webhook deliveries, read on 18 October 2026.
The checks, most likely first
- The header is missing. GitHub says the
X-Hub-Signature-256header is not sent at all if the webhook has no secret. Open the webhook's settings and set one. If you do not know the current value, GitHub says to update it, since it cannot be read back. - Wrong header or algorithm. Use
X-Hub-Signature-256with HMAC-SHA256. The olderX-Hub-Signatureheader is HMAC-SHA1 and is "only included for legacy purposes". Computing SHA-256 and comparing it with the SHA-1 header (or the reverse) never matches. - The body was changed before you hashed it. The hash covers the payload bytes GitHub sent. Parsing to JSON and re-stringifying, a body-parser middleware that runs first, or a proxy or load balancer that alters the payload or headers all break the match. In Express, register the webhook route with
express.raw({type: "*/*"})before anyexpress.json(). - Wrong secret. The secret is the string typed in the webhook's "Secret" box, used as-is as the HMAC key. A different webhook, a different environment, or a secret with a trailing newline copied from a file will not match.
- Encoding. The digest is a hex string that always starts with
sha256=. If your language or server sets a character encoding, handle the payload as UTF-8, because payloads can contain unicode characters. - Comparing with
==. Not a cause of mismatch, but GitHub tells you never to use a plain equality operator: use a constant-time compare such ascrypto.timingSafeEqual.
Check your code against GitHub's test vector
GitHub publishes a test pair: secret It's a Secret to Everybody, payload Hello, World!, which must give 757107ea0eb2509fc211221cce984b8a37570b6d7586c22c46f4379c8b043e17. If your function returns that, the algorithm is right and the problem is one of the inputs (secret, body, header).
import crypto from "node:crypto";
export function verifyGitHub(rawBody, signatureHeader, secret) {
if (!secret || !signatureHeader) return { ok: false, reason: "missing secret or X-Hub-Signature-256 header" };
const want = Buffer.from("sha256=" + crypto.createHmac("sha256", secret).update(rawBody).digest("hex"));
const got = Buffer.from(String(signatureHeader));
if (got.length !== want.length) return { ok: false, reason: "length differs: wrong algorithm, wrong header or truncated" };
return crypto.timingSafeEqual(got, want) ? { ok: true } : { ok: false, reason: "mismatch: wrong secret or modified body" };
}
// GitHub's published vector:
const sig = "sha256=757107ea0eb2509fc211221cce984b8a37570b6d7586c22c46f4379c8b043e17";
console.log(verifyGitHub("Hello, World!", sig, "It's a Secret to Everybody")); // { ok: true }
Pass rawBody as the Buffer or exact string received. The length check comes first because timingSafeEqual throws when the two buffers differ in length.
Which input is wrong?
Paste the secret, raw body and header into the free signature debugger. It runs in your browser, recomputes the GitHub signature and says whether the cause looks like a trailing newline, a re-serialised body or a secret with stray whitespace. The general mismatch checklist covers other senders, the Stripe page covers its timestamped header, and the Shopify page covers its base64 header.
Receiving from more than GitHub? The Webhook Spec Atlas ($9) lays out the header, signed string, encoding, replay window, timeout, retries and dedupe key for 16 senders, each read from the vendor's own docs, with tested verifiers.