ToolsFree tool
GitHub webhook signature verifier
Check an X-Hub-Signature-256 header against your webhook's secret and see why verification fails. Nothing you paste leaves your browser.
The result shows up here
Paste the webhook's secret, the X-Hub-Signature-256 header, and the raw payload from a delivery, then verify. To see how it works first, use Try an example.
What gets signed
HMAC-SHA256(secret, body)The body was parsed before it was verified
GitHub signs the exact payload bytes. Middleware such as express.json() hands you an object, and serializing it again rarely reproduces those bytes. Read the raw body, verify it, then parse it. Payloads can contain Unicode, so handle the bytes as UTF-8.
The webhook sends form data
With the content type set to application/x-www-form-urlencoded, GitHub signs the form body: payload= with the JSON URL-encoded inside it. Verifying the JSON you read out of the payload field fails. Verify the raw form body, or set the webhook's content type to application/json.
The wrong secret
Repository, organization, and GitHub App webhooks each have their own secret. If a webhook has no secret at all, GitHub sends no signature header.
The legacy header was used
X-Hub-Signature carries an HMAC-SHA1 signature and exists only for compatibility; X-Hub-Signature-256 uses HMAC-SHA256. Computing one algorithm and comparing against the other header always fails.
The prefix was dropped on one side
The header is sha256= followed by the hex digest. Either compare the whole header with "sha256=" + digest, or strip the prefix and compare the digests. Mixing the two never matches.
Something between GitHub and your code changed the body
A proxy, load balancer, or API gateway that re-encodes the body changes the bytes your code sees. Verify where the original request arrives, or make sure anything in front of it passes the body through untouched.
import { createHmac, timingSafeEqual } from "node:crypto";
// rawBody must be the exact bytes received (a Buffer), not re-serialized JSON.
export function verifyGitHubWebhook(rawBody, headers, secret) {
const header = headers["x-hub-signature-256"] ?? "";
if (!header.startsWith("sha256=")) return false;
const signature = Buffer.from(header.slice("sha256=".length), "hex");
const expected = createHmac("sha256", secret).update(rawBody).digest();
return signature.length === expected.length && timingSafeEqual(signature, expected);
}With Express, use express.raw({ type: "application/json" }) on the webhook route so rawBody is the original Buffer. @octokit/webhooks does the same check in verify().
GitHub signs each delivery with the webhook's secret and sends:
X-Hub-Signature-256:sha256=followed by the HMAC-SHA256 of the payload, as hex.X-Hub-Signature: the same with HMAC-SHA1, for older integrations.X-GitHub-Delivery: a unique ID for the delivery, handy for matching a request to Recent Deliveries in the webhook's settings.
To verify a request:
- Compute HMAC-SHA256 of the raw request body, keyed with the secret's UTF-8 bytes.
- Hex-encode the result and add the
sha256=prefix. - Compare it in constant time with
X-Hub-Signature-256.
The signature doesn't include a timestamp, so it doesn't expire. GitHub publishes a test case to check your code against: the secret It's a Secret to Everybody and the payload Hello, World! give sha256=757107ea0eb2509fc211221cce984b8a37570b6d7586c22c46f4379c8b043e17.
Sending webhooks from your own product? Cascadehook signs every delivery with Standard Webhooks, retries failures for up to 72 hours, and keeps every attempt for replay. Create a project or try the Standard Webhooks verifier.
GitHub is a trademark of GitHub, Inc.. Cascadehook isn't affiliated with or endorsed by GitHub.