GitHub webhook signature validator
Paste the raw body, your webhook secret and the X-Hub-Signature-256 GitHub sent — get an instant match / no-match. When it fails, the validator points at the exact reason (trimmed secret, stray newline) so you stop guessing.
Everything runs in your browser via WebCrypto — the secret never leaves your machine. Two things sink almost every failed GitHub verification: hashing a re-serialized body instead of the raw bytes, and forgetting the sha256= prefix. Both are covered below.
Validate the signature
The GitHub tab is preselected. Paste sha256=…, your secret, and the raw JSON body exactly as delivered.
What GitHub actually signs
GitHub computes an HMAC-SHA256 of the raw request body, keyed with the secret you set on the webhook, hex-encodes it and prefixes sha256=. It sends that in X-Hub-Signature-256. Your job is to recompute the identical value and compare.
X-Hub-Signature-256: sha256=7d38cdd689735b008b3c702edd92eea23791c5f6f2... X-Hub-Signature: sha1=e2c8a... ← legacy SHA-1, don't rely on it X-GitHub-Event: push X-GitHub-Delivery: 72d3162e-cc78-11e3-81ab-4c9367dc0958
| Element | Detail |
|---|---|
| algorithm | HMAC-SHA256 (X-Hub-Signature-256) |
| message | the RAW request body bytes |
| key | the webhook Secret from repo settings |
| encoding | lowercase hex |
| prefix | sha256= (must be included before comparing) |
| legacy header | X-Hub-Signature = sha1=… — deprecated |
Trap #1 — the raw body, not the re-serialized JSON
This is the number-one cause of "the signature never matches". The moment a body-parser turns the payload into an object and you feed JSON.stringify(req.body) to the HMAC, the bytes differ from what GitHub hashed — different key order, different spacing, escaped Unicode. The HMAC of different bytes is a different signature. You must hash the exact buffer received.
import express from "express";
const app = express();
// Keep the raw bytes on the request; DON'T verify against express.json() output.
app.use("/webhook", express.raw({ type: "application/json" }));
app.post("/webhook", (req, res) => {
const raw = req.body; // a Buffer — the exact bytes GitHub signed
if (!verify(raw, req.headers["x-hub-signature-256"])) return res.sendStatus(401);
const event = JSON.parse(raw.toString("utf8")); // parse only AFTER verifying
res.sendStatus(204);
});Trap #2 — constant-time comparison
Never compare signatures with === or ==. String equality short-circuits on the first differing character, and that timing difference leaks how many leading bytes were correct — enough to forge a signature byte by byte over many requests. Use the constant-time primitive for your language. Note timingSafeEqual throws if the two buffers differ in length, so guard the length first (a length mismatch is already a no-match).
import crypto from "crypto";
function verify(rawBody, header) {
const expected =
"sha256=" + crypto.createHmac("sha256", process.env.WEBHOOK_SECRET).update(rawBody).digest("hex");
const a = Buffer.from(header || "", "utf8");
const b = Buffer.from(expected, "utf8");
return a.length === b.length && crypto.timingSafeEqual(a, b); // constant-time
}import hmac, hashlib
def verify(raw_body: bytes, header: str) -> bool:
expected = "sha256=" + hmac.new(
SECRET.encode(), raw_body, hashlib.sha256
).hexdigest()
return hmac.compare_digest(expected, header or "") # constant-timefunction verify(string $rawBody, ?string $header): bool {
$expected = 'sha256=' . hash_hmac('sha256', $rawBody, getenv('WEBHOOK_SECRET'));
return is_string($header) && hash_equals($expected, $header); // constant-time
}
// $rawBody = file_get_contents('php://input'); // never the parsed $_POSTThe secret, and returning 401
Set the secret in Repository Settings → Webhooks → Add/edit webhook → Secret. GitHub only sends X-Hub-Signature-256 when a secret exists — a missing header means no secret is configured, so reject those too. On any mismatch, respond 401 (or 403) and do not process the payload. Answer within GitHub's 10-second delivery timeout, then do heavy work asynchronously.
Related tools
Need to fire a signed test event at your handler instead of verifying a real one? Use the tester. The multi-provider validator covers Stripe, Shopify and Slack with the same engine.
Questions fréquentes
Why does my signature never match even though the secret is right?
Almost always because you HMAC a re-serialized body. GitHub signs the exact raw bytes it sent. If your framework parses the JSON and you then JSON.stringify it back, key order and whitespace change, so the HMAC changes. Capture and hash the raw body buffer before any parsing.
What is the sha256= prefix?
GitHub sends X-Hub-Signature-256 as the literal string "sha256=" followed by the hex HMAC. When you build the expected value you must add the same prefix before comparing — 'sha256=' + hmac.digest('hex'). Comparing the bare hex against the prefixed header will always fail.
Should I still use X-Hub-Signature (sha1)?
No. X-Hub-Signature is the legacy SHA-1 header GitHub still sends for backward compatibility. Verify X-Hub-Signature-256 (SHA-256) instead; only fall back to SHA-1 if you support very old integrations. This tool checks both — a sha1= prefix switches it to SHA-1 automatically.
Why constant-time comparison?
A normal === on strings returns as soon as two characters differ, so the time it takes leaks how many leading characters were correct. An attacker can exploit that to guess the signature byte by byte. crypto.timingSafeEqual (Node), hmac.compare_digest (Python) and hash_equals (PHP) always take the same time regardless of where the mismatch is.
Where do I set the webhook secret?
Repository (or organization) Settings → Webhooks → Add webhook / edit → the Secret field. GitHub only sends the X-Hub-Signature-256 header when a secret is configured — if the header is missing, you never set one.