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-Deliveryheader 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-Eventheader 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.