CanHook
Features Pricing Docs FAQ Blog Log in Get started free

How to Verify GitHub Webhook Signatures with HMAC-SHA256

By The CanHook Team · August 24, 2026 · 1291 words
In short

GitHub webhook signature verification means recomputing an HMAC-SHA256 digest over the exact raw request body with your webhook secret, then comparing it to the X-Hub-Signature-256 header using a constant-time function. Verify before you parse JSON, or the check runs against the wrong bytes and fails even with the right secret.

Webhook signature verification is the process of confirming that a request claiming to come from GitHub was actually sent by GitHub, not by someone who found your endpoint URL. GitHub signs every delivery with an HMAC-SHA256 digest of the raw request body, sent in the X-Hub-Signature-256 header. You verify it by recomputing that digest yourself with your webhook's secret against the exact bytes GitHub sent, then comparing the two in constant time. Get any part of that wrong — parse JSON before verifying, hash a re-serialized body, compare with a plain string equals — and the check either rejects every real delivery or protects nothing. CanHook is a webhook inspector: point a GitHub webhook at a CanHook catch URL and you can see the exact raw body and headers GitHub sent, which is the fastest way to find out why your signature check disagrees with GitHub's.

How does GitHub sign a webhook delivery?

Every delivery carries two signature headers computed over the same raw request body with the same secret, using two different algorithms.

HeaderAlgorithmUse it?
X-Hub-Signature-256HMAC-SHA256, hex-encoded, prefixed sha256=Yes — this is the one to verify
X-Hub-SignatureHMAC-SHA1, hex-encoded, prefixed sha1=No — kept only for backward compatibility with old integrations

Both headers exist on every delivery, but there is no reason to write new code against the SHA1 one. GitHub's own webhook delivery validation guide recommends the SHA256 header, and it is what the rest of this article verifies.

Why does hashing the parsed body break verification?

The digest is computed over the exact byte sequence GitHub sent on the wire, not over any representation of the data. Most web frameworks parse the request body into an object before your route handler ever sees it — and JSON parsing followed by re-serialization changes whitespace, key order, and number formatting. Hash that re-serialized JSON instead of the original bytes and the digest will not match, even though the payload is semantically identical and the secret is correct. The fix is to read and hash the raw body before any JSON parser touches it, then parse it afterward, only once verification passes.

How do you compute and compare the signature?

The shape is the same in every language: read the raw body as bytes, compute an HMAC-SHA256 keyed with your webhook secret, hex-encode it, prefix it with sha256=, and compare that string to the header using a constant-time function.

// Node.js / Express — capture the raw body before the JSON parser runs
const crypto = require('crypto');

function verifyGithubSignature(rawBody, signatureHeader, secret) {
  if (!signatureHeader || !signatureHeader.startsWith('sha256=')) return false;
  const expected = 'sha256=' + crypto.createHmac('sha256', secret).update(rawBody).digest('hex');
  const a = Buffer.from(expected);
  const b = Buffer.from(signatureHeader);
  if (a.length !== b.length) return false;
  return crypto.timingSafeEqual(a, b);
}

app.post('/webhooks/github', express.raw({ type: 'application/json' }), (req, res) => {
  const ok = verifyGithubSignature(req.body, req.get('X-Hub-Signature-256'), process.env.GITHUB_WEBHOOK_SECRET);
  if (!ok) return res.status(401).send('bad signature');
  const payload = JSON.parse(req.body);
  res.sendStatus(200);
});
// PHP — php://input gives you the raw body exactly once, before decoding
function verify_github_signature(string $rawBody, ?string $signatureHeader, string $secret): bool
{
    if ($signatureHeader === null || !str_starts_with($signatureHeader, 'sha256=')) {
        return false;
    }
    $expected = 'sha256=' . hash_hmac('sha256', $rawBody, $secret);
    return hash_equals($expected, $signatureHeader);
}

$rawBody = file_get_contents('php://input');
$signature = $_SERVER['HTTP_X_HUB_SIGNATURE_256'] ?? null;

if (!verify_github_signature($rawBody, $signature, getenv('GITHUB_WEBHOOK_SECRET'))) {
    http_response_code(401);
    exit;
}

$payload = json_decode($rawBody, true);

Two details matter more than the rest of the code: read the raw body exactly once, before anything decodes it, and compare with hash_equals() or crypto.timingSafeEqual() instead of == or ===. A plain comparison exits on the first mismatched byte, which leaks timing information an attacker can use to guess a valid signature one byte at a time.

Why does verification fail even with the right secret?

Almost every real-world failure traces back to one of these:

  • A body-parsing middleware ran before your handler and you are hashing req.body instead of the raw bytes.
  • The framework trims or re-encodes the body (charset conversion, trailing newline stripped) before you can read it raw.
  • The comparison uses a language-level ==, which is not constant-time and is also case-sensitive against a hex string you built in the wrong case.
  • The secret in your environment has trailing whitespace from a copy-paste, which is invisible in most editors and changes the HMAC key entirely.
  • You are testing with a payload you typed by hand instead of a real delivery, so there is no valid signature to match against in the first place.

The last one is the most common self-inflicted failure: you cannot manually craft a signature-verified test payload without also having the real secret and doing the same HMAC computation your production code does, so a hand-written test payload just confirms your own math, not GitHub's.

How do you test signature verification without deploying?

The safest way to test verification is against a real delivery's real bytes, not a payload you assembled yourself. Point the GitHub webhook at a CanHook catch URL instead of your application, trigger one real event from the repository, and CanHook stores the exact method, headers, and raw body GitHub sent — including both signature headers, byte for byte.

curl -X POST https://canhook.com/h/abc123def456 \
  -H "Content-Type: application/json" \
  -H "X-Hub-Signature-256: sha256=<example-only>" \
  -d '{"zen":"Design for failure."}'

That curl call is just illustrating the shape of what GitHub sends — in practice you trigger the event from GitHub itself so the signature is real. Once it lands in a CanHook endpoint you can inspect the captured request in the dashboard, confirm the exact header name and value GitHub used, and replay that same captured request against your local development server as many times as you need while you fix your verification code. No tunnel, no faked payload, no risk of leaking your webhook secret into a public request-bin.

How do you rotate a webhook secret without downtime?

GitHub only lets a webhook have one active secret at a time, so a naive rotation has a window where either the old or the new secret fails. Avoid it by accepting both secrets in your code before you touch GitHub's settings:

  1. Add the new secret to your application config alongside the old one, and update your verification function to accept a match against either.
  2. Deploy that change and confirm existing deliveries still verify against the old secret.
  3. Update the webhook secret in GitHub's repository settings to the new value.
  4. Trigger a test delivery (GitHub's webhook settings page has a redeliver button) and confirm it verifies against the new secret.
  5. Remove the old secret from your application config once you have seen at least one successful delivery on the new one.

Is this worth building yourself?

The verification function itself is genuinely small — a dozen lines in most languages. What is not small is safely testing it: you need a real endpoint GitHub can reach, a way to see the exact bytes it sent when your check disagrees, and a way to replay that exact delivery while you fix the code, all without pasting a live webhook secret into a scratch script or a public tool you don't control. That is the part CanHook exists for. Free endpoints keep the last 100 requests for 24 hours, which is enough for one debugging session; see the plans if you need longer retention while you build.

Create a free CanHook endpoint, point one GitHub webhook at it, and trigger a real delivery — you will have the exact raw body and both signature headers in front of you in under a minute.

Frequently asked questions

What header does GitHub use for webhook signatures?

GitHub sends X-Hub-Signature-256, an HMAC-SHA256 digest of the raw request body prefixed with sha256=, plus a legacy X-Hub-Signature using SHA1 that new integrations should ignore in favor of the SHA256 one.

Why does my GitHub webhook signature never match?

The most common cause is hashing a re-serialized or parsed body instead of the exact raw bytes GitHub sent. Most frameworks parse JSON before your handler runs, which changes whitespace and key order and breaks the digest even with the correct secret.

Is comparing webhook signatures with == safe?

No. A standard string comparison exits on the first mismatched byte, leaking timing information an attacker could use to guess a valid signature one byte at a time. Use hash_equals() in PHP or crypto.timingSafeEqual() in Node.js instead.

How do I rotate a GitHub webhook secret without downtime?

Accept both the old and new secret in your verification code first, then update the secret in GitHub's webhook settings, confirm a delivery verifies against the new one, and only then remove the old secret from your code.

Can I test GitHub webhook signature verification without deploying my app?

Yes. Point the GitHub webhook at a CanHook catch URL, trigger a real event, and inspect the exact headers and raw body GitHub sent. You can replay that captured request against your local development server as many times as you need.

Does GitHub retry a webhook delivery that fails signature verification?

GitHub logs the delivery as failed but does not automatically retry a rejected signature the way it retries a timeout or server error. You have to redeliver it manually from the repository's webhook settings page.