WebhookToolkit
GitHub

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.

Tout s'exécute dans votre navigateur avec l'API Web Crypto. Votre secret de signature n'est jamais envoyé à nos serveurs — vérifiez-le dans l'onglet Réseau.

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.

The header GitHub sends
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
ElementDetail
algorithmHMAC-SHA256 (X-Hub-Signature-256)
messagethe RAW request body bytes
keythe webhook Secret from repo settings
encodinglowercase hex
prefixsha256= (must be included before comparing)
legacy headerX-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.

Node / Express — capture the raw body
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).

Node
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
}
Python (Flask / Django)
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-time
PHP
function 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 $_POST

The 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.

GitHub webhook testerWebhook signature validatorStripe signature validator

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.

GitHub Webhook Signature Validator — check X-Hub-Signature-256 · Webhook Toolkit