Use the backend webhook signature utility whenever a third-party service sends data into the API. This keeps webhook routes reusable and prevents unauthenticated traffic from reaching integration logic.
Every webhook request is authenticated using HMAC-SHA256 (Hash-based Message Authentication Code with SHA-256).
The sender (GitHub) computes:
signature = "sha256=" + HMAC_SHA256(key=WEBHOOK_SECRET, message=RAW_REQUEST_BODY)
The receiver (this backend) independently computes the same signature from the raw body and the shared secret, then compares both values in constant time to prevent timing attacks.
⚠️ Timing-safe comparison is mandatory.
A naïve string comparison (===) short-circuits on the first mismatched character, leaking information about how many characters match.
Always usecrypto.timingSafeEqual()(Node.js) orhmac.compare_digest()(Python) — both are used in the examples below and in the backend implementation.
const crypto = require("crypto");
/**
* Verify a GitHub-style HMAC-SHA256 webhook signature.
* @param {string} secret - Shared webhook secret
* @param {string} payload - Raw request body (string, not parsed JSON)
* @param {string} header - Value of X-Hub-Signature-256 header, e.g. "sha256=abc123..."
* @returns {boolean}
*/
function verifyGitHubSignature(secret, payload, header) {
if (!header || !header.startsWith("sha256=")) return false;
const expected = crypto
.createHmac("sha256", secret)
.update(payload, "utf8")
.digest("hex");
const received = header.slice("sha256=".length);
// Timing-safe comparison — never use === here
return crypto.timingSafeEqual(
Buffer.from(expected, "hex"),
Buffer.from(received, "hex")
);
}
// Express usage
app.post("/api/webhooks/github", express.raw({ type: "application/json" }), (req, res) => {
const sig = req.headers["x-hub-signature-256"];
if (!verifyGitHubSignature(process.env.GITHUB_WEBHOOK_SECRET, req.body.toString("utf8"), sig)) {
return res.status(401).json({ error: "Invalid signature" });
}
res.json({ status: "ok" });
});import hmac
import hashlib
def verify_github_signature(secret: str, payload: bytes, header: str) -> bool:
"""
Verify a GitHub-style HMAC-SHA256 webhook signature.
:param secret: Shared webhook secret (plain string)
:param payload: Raw request body as bytes
:param header: Value of X-Hub-Signature-256 header, e.g. "sha256=abc123..."
:returns: True if the signature is valid, False otherwise
"""
if not header or not header.startswith("sha256="):
return False
expected = hmac.new(
key=secret.encode("utf-8"),
msg=payload,
digestmod=hashlib.sha256,
).hexdigest()
received = header[len("sha256="):]
# Timing-safe comparison — never use == here
return hmac.compare_digest(expected, received)
# Flask usage
from flask import Flask, request, abort
app = Flask(__name__)
@app.post("/api/webhooks/github")
def github_webhook():
sig = request.headers.get("X-Hub-Signature-256", "")
if not verify_github_signature(
secret=os.environ["GITHUB_WEBHOOK_SECRET"],
payload=request.get_data(),
header=sig,
):
abort(401)
return {"status": "ok"}backend/src/webhooks/signatureVerification.tsprovides a generic HMAC signature verifier.- The same module exports GitHub defaults for
X-Hub-Signature-256andsha256=...signatures. /api/webhooks/githubis protected by the middleware and can be extended with GitHub event handling later.
Set GITHUB_WEBHOOK_SECRET in the backend environment, then protect the route with the GitHub middleware:
app.post(
"/api/webhooks/github",
createGitHubWebhookSignatureMiddleware(() => process.env.GITHUB_WEBHOOK_SECRET),
handler,
);The middleware verifies the raw request body against the X-Hub-Signature-256 header and returns 401 when the signature is missing or invalid.
Testing GitHub webhooks locally requires a public URL that GitHub can reach. ngrok creates a secure tunnel from GitHub to your local machine without exposing your actual IP.
Download ngrok for your platform:
# macOS (Homebrew)
brew install ngrok
# Or download from https://ngrok.com/downloadIn one terminal, start the Express backend:
npm run dev:backendThe backend will be running on http://localhost:3001.
In a second terminal, create a tunnel to port 3001:
ngrok http 3001This output will look like:
ngrok (Ctrl+C to quit)
Add authentication via the AuthToken option.
Add a domain via the API base url in the web inspect tool.
Session Status online
Account [your-email@example.com]
Version 3.3.5
Region us-central (California)
Web Interface http://127.0.0.1:4040
Forwarding https://1a2b-203-0-113-42.ngrok.io -> http://localhost:3001
Save the public URL: https://1a2b-203-0-113-42.ngrok.io (this changes every restart)
- Navigate to your repository → Settings → Webhooks → Add webhook
- Set the following:
- Payload URL:
https://1a2b-203-0-113-42.ngrok.io/api/webhooks/github(use your ngrok URL) - Content type:
application/json - Secret: Generate a strong secret (e.g.,
openssl rand -hex 32) — save this value - Events: Select which events to trigger on (e.g., "Pull requests", "Issues")
- Payload URL:
- Click Add webhook
Create or update backend/.env:
# GitHub webhook secret from GitHub UI
GITHUB_WEBHOOK_SECRET=your_generated_secret_hereRestart the backend to pick up the environment variable:
# Stop and restart npm run dev:backendTrigger a webhook event by:
- Opening an issue or editing a PR in your test repository
- Go to your GitHub webhook settings → scroll down to "Recent Deliveries"
- Click on a delivery to see the request and response
In the Response tab, you should see:
Success (200):
{
"status": "ok",
"message": "Webhook received and signature verified"
}Failure (401):
{
"error": "Invalid signature"
}Open http://127.0.0.1:4040 in your browser to:
- See real-time request/response logs
- Inspect request headers and body
- Replay requests for testing
- Debug signature verification failures
Problem: ngrok URL returns Connection refused
Solution:
- Make sure the backend is running on
localhost:3001 - Restart ngrok and copy the new public URL
- Update your GitHub webhook Payload URL with the new ngrok address
Problem: All webhook requests fail with Invalid signature
Causes & fixes:
- Secret mismatch — Verify the secret in
backend/.envmatches exactly what you set in GitHub (case-sensitive) - Raw body not captured — The signature middleware must receive the raw request body; Express should not parse JSON before verification
- Secret copied incorrectly — Re-generate the secret in GitHub UI and update
.env, restart backend - Whitespace in secret — Ensure no leading/trailing spaces in
backend/.env
Test the secret locally:
# Generate a test payload and verify signature
node scripts/test-webhook-signature.jsProblem: GitHub shows no recent deliveries
Fixes:
- Verify the Payload URL is the current ngrok URL (it changes on restart)
- Make sure the webhook is set to trigger on the right events
- Check GitHub webhook settings → Recent deliveries tab — GitHub logs all attempts
- Manually trigger an event (create an issue, open a PR) to test
Problem: Webhook was working, now fails
Why: ngrok tunnels expire and change when restarted or when the free session ends
Solution:
- Restart ngrok:
ngrok http 3001 - Copy the new public URL from the output
- Update GitHub webhook settings with the new URL
- Save changes
- Reuse
createWebhookSignatureMiddleware(...)for any provider that signs requests with an HMAC header. - Keep raw body capture enabled so verification uses the original bytes sent by the provider.
- Add provider-specific wrappers when a new integration has a stable header name, prefix, and algorithm.
- Reject unsigned traffic before parsing event-specific fields or mutating any application state.
- GitHub Webhook Documentation
- ngrok Documentation
- Contributing Guide — includes webhook-related contribution areas