All Webhook Errors

GitHub Webhook Signature Mismatch — Causes & Fixes

GitHub signs every webhook delivery with HMAC-SHA256, exposed as X-Hub-Signature-256: sha256=<hex>. Verification failures usually come from one of four issues — different from Stripe's failure modes because GitHub's signing scheme has different quirks.

Root Causes

1. Verifying with SHA1 instead of SHA256

GitHub also sends X-Hub-Signature (SHA1) for backward compatibility. Don't use it. The SHA1 variant is deprecated; verify against X-Hub-Signature-256 only.

2. Body was parsed before signature check

Same root cause as Stripe — GitHub's HMAC is over the raw bytes. If body-parser or express.json() ran first, the bytes are different. Use express.raw().

3. Content-type confusion

GitHub webhook config has two content-type options: application/json (recommended) and application/x-www-form-urlencoded (legacy). The form-encoded mode wraps the JSON inside a payload= form field. Many tutorials don't handle the form-encoded mode. Set the GitHub webhook to application/json in repository settings — there's no good reason to use form-encoded for new integrations.

4. Standard string comparison instead of constant-time

Using === to compare HMACs leaks timing information. Use crypto.timingSafeEqual. This isn't usually the cause of mismatches — but it's a real security bug worth fixing alongside.

Fix It — Working GitHub Verification

import express from 'express'
import crypto from 'node:crypto'

const app = express()
const secret = process.env.GITHUB_WEBHOOK_SECRET

app.post('/webhooks/github',
  express.raw({ type: 'application/json' }),
  (req, res) => {
    const sig = req.headers['x-hub-signature-256']
    if (!sig) return res.status(401).send('missing signature')

    const expected = 'sha256=' + crypto
      .createHmac('sha256', secret)
      .update(req.body)
      .digest('hex')

    const sigBuf = Buffer.from(sig)
    const expBuf = Buffer.from(expected)
    if (sigBuf.length !== expBuf.length || !crypto.timingSafeEqual(sigBuf, expBuf)) {
      return res.status(401).send('signature mismatch')
    }

    const event = JSON.parse(req.body.toString())
    // event.action, event.repository, etc.
    res.status(200).send('ok')
  }
)

Python Equivalent

import hmac, hashlib
from flask import Flask, request

app = Flask(__name__)
SECRET = os.environ['GITHUB_WEBHOOK_SECRET'].encode()

@app.post('/webhooks/github')
def github_webhook():
    sig = request.headers.get('X-Hub-Signature-256', '')
    body = request.get_data()  # raw bytes

    expected = 'sha256=' + hmac.new(SECRET, body, hashlib.sha256).hexdigest()
    if not hmac.compare_digest(sig, expected):
        return 'invalid signature', 401

    payload = request.get_json()
    return 'ok', 200

GitHub-Specific Gotchas

  • GitHub does not include a timestamp in the signed material. There's no built-in replay-attack defense — use idempotency at the handler level.
  • The X-GitHub-Delivery header is your event ID. Use it as the idempotency key.
  • GitHub retries up to 8 times across ~24 hours, with exponential backoff. Plan your idempotency store retention to cover at least 48 hours.
  • The X-GitHub-Event header tells you the event type (push, pull_request, etc.). Switch on it before applying type-specific schema validation.

How to Reproduce

In GitHub: repository → Settings → Webhooks → click your webhook → "Recent Deliveries" tab → click any delivery → "Redeliver" button. The signature header in the redelivery is identical to the original. If your handler accepts one but not the other, your verification logic is non-deterministic.

Frequently Asked Questions

Should I support both X-Hub-Signature and X-Hub-Signature-256?

Verify against the 256 version only. SHA1 is deprecated and supporting both adds attack surface for downgrade attacks.

How do I test GitHub webhooks locally?

Use Smee.io (GitHub's official forwarder) or WebhookWhisper to expose localhost. The X-Hub-Signature-256 header passes through unchanged, so verification works the same locally and in prod.

Why do some GitHub webhook examples not include 'sha256=' prefix in the comparison?

They're wrong. The header value is literally 'sha256=<hex>', so your expected value must include the prefix to match. Comparing the bare hex digests against the prefixed header always fails.

Debug This Error in Real Time

WebhookWhisper captures every webhook request with full headers, body, and timing — so you can see exactly what the provider sent and reproduce the error instantly.

Start Debugging Free
GitHub Webhook Signature Mismatch — Causes & Fixes (2026) | WebhookWhisper