To validate a GitHub webhook signature, compute an HMAC-SHA256 hash of the raw request body using your webhook secret, then compare the result against the X-Hub-Signature-256 header that GitHub sends with every delivery. If the two values match using a timing-safe comparison, the payload is authentic and has not been tampered with in transit.
Every webhook that GitHub sends is signed with the secret you configure in your repository or organization settings. Skipping this check means any internet-connected actor who discovers your endpoint URL can send forged payloads — triggering deployments, creating issues, or modifying data in your system. The verification process takes fewer than twenty lines of code, and there is no good reason to skip it in production. This guide walks through exactly how the signature works, gives you copy-paste Node.js code, covers the most common failure modes, and shows how HookNexus can cut your debugging time in half.
How GitHub webhook signatures work
When you create a webhook in GitHub and enter a secret, GitHub stores that secret on its side. Each time an event fires, GitHub serializes the payload as JSON, computes an HMAC-SHA256 digest of that JSON body using your secret as the key, and attaches the result as a hex string in the X-Hub-Signature-256 HTTP header.
The header value looks like this:
X-Hub-Signature-256: sha256=a]b1c2d3e4f5...
The prefix sha256= tells you which algorithm was used. Your job on the receiving end is straightforward: take the exact bytes of the request body, run the same HMAC-SHA256 computation with the same secret, and check whether the output matches.
X-Hub-Signature (SHA1) vs X-Hub-Signature-256 (SHA256)
GitHub still sends the older X-Hub-Signature header that uses HMAC-SHA1. SHA1 is considered weak for new applications, and GitHub recommends using the SHA256 variant. Your verification code should read X-Hub-Signature-256 and fall back to X-Hub-Signature only if you need to support legacy configurations. All examples in this article use SHA256.
Step-by-step signature verification
Step 1 — Read the raw request body
The most critical requirement is that you verify the signature against the exact bytes GitHub sent. If your web framework parses JSON before you read the body, the re-serialized output may differ from the original — a single whitespace change or key reordering will produce a completely different hash.
In Express, this means capturing the raw buffer at the middleware level before express.json() runs. In other frameworks, the approach varies, but the principle is the same: get the untouched body.
Step 2 — Compute the HMAC-SHA256 hash
With the raw body in hand, use your language’s cryptography library to compute the digest. Here is the Node.js version:
const crypto = require("crypto");
function computeSignature(secret, payload) {
return (
"sha256=" +
crypto.createHmac("sha256", secret).update(payload, "utf8").digest("hex")
);
}
payload must be the raw string or Buffer — not a parsed-and-restringified object. secret is the same value you entered in the GitHub webhook settings page.
Step 3 — Compare with the header using timing-safe comparison
Never compare the computed hash to the header value with ===. A strict equality check returns false as soon as it encounters the first differing character, which leaks timing information an attacker could use to reconstruct the valid signature one character at a time.
Instead, use crypto.timingSafeEqual, which always compares every byte regardless of where a mismatch occurs:
const crypto = require("crypto");
function verifySignature(secret, payload, signatureHeader) {
const expected = computeSignature(secret, payload);
const sigBuffer = Buffer.from(signatureHeader, "utf8");
const expBuffer = Buffer.from(expected, "utf8");
if (sigBuffer.length !== expBuffer.length) {
return false;
}
return crypto.timingSafeEqual(sigBuffer, expBuffer);
}
The length check before timingSafeEqual is necessary because the function throws if the two buffers differ in length.
Step 4 — Reject or accept the request
If verifySignature returns false, respond with HTTP 401 or 403 and stop processing. Do not log the full secret in your error output — log enough context to debug (the expected prefix, the header you received, a truncated hash) without exposing credentials.
If it returns true, the request is authentic. Proceed with your event handling logic.
Complete verification middleware example
Below is a production-ready Express middleware that ties all the steps together. Drop it into your webhook route and it handles raw body capture, signature computation, and timing-safe comparison:
const crypto = require("crypto");
const express = require("express");
const WEBHOOK_SECRET = process.env.GITHUB_WEBHOOK_SECRET;
function verifyGitHubWebhook(req, res, next) {
const signatureHeader = req.headers["x-hub-signature-256"];
if (!signatureHeader) {
return res.status(401).json({ error: "Missing signature header" });
}
const rawBody = req.body; // Buffer, captured via express.raw()
const expected =
"sha256=" +
crypto
.createHmac("sha256", WEBHOOK_SECRET)
.update(rawBody)
.digest("hex");
const sigBuffer = Buffer.from(signatureHeader, "utf8");
const expBuffer = Buffer.from(expected, "utf8");
if (sigBuffer.length !== expBuffer.length) {
return res.status(401).json({ error: "Signature mismatch" });
}
if (!crypto.timingSafeEqual(sigBuffer, expBuffer)) {
return res.status(401).json({ error: "Signature mismatch" });
}
// Parse the verified body into JSON for downstream handlers
req.body = JSON.parse(rawBody.toString("utf8"));
next();
}
const app = express();
// Capture the raw body as a Buffer on the webhook route
app.post(
"/webhooks/github",
express.raw({ type: "application/json" }),
verifyGitHubWebhook,
(req, res) => {
const event = req.headers["x-github-event"];
console.log(`Received verified ${event} event`);
res.status(200).json({ ok: true });
}
);
app.listen(3000, () => {
console.log("Listening on port 3000");
});
Key details in this middleware:
express.raw({ type: "application/json" })is applied only to the webhook route so it does not interfere with other endpoints that useexpress.json().- The raw
Bufferis passed directly tocreateHmac, avoiding any encoding round-trip. - After verification passes, the body is parsed once and attached back to
req.bodyfor the next handler.
Common signature verification failures
Signature mismatches are one of the most frequent issues developers hit when integrating GitHub webhooks. The root cause almost always falls into one of four categories.
Wrong secret
The secret configured in the GitHub webhook settings does not match the value your application reads from its environment. Copy-paste errors, trailing newlines in .env files, and deploying without updating the secret after rotation are the usual culprits. Double-check by printing the first four characters of the loaded secret at startup during debugging, then remove that log before going to production.
Body was parsed before verification
This is the single most common cause. If express.json() or a similar body parser runs before your verification middleware, req.body is a JavaScript object, not the original bytes. When you JSON.stringify it back, the output is unlikely to match the original payload byte-for-byte. The fix is to use express.raw() on the webhook route and parse manually after verification, exactly as shown in the middleware above.
SHA1 vs SHA256 mismatch
If your code reads X-Hub-Signature (SHA1) but computes an SHA256 hash — or the reverse — the comparison will always fail. Make sure the header name and the algorithm in createHmac agree. Prefer X-Hub-Signature-256 with sha256 for all new integrations.
Missing header
If the signature header is absent entirely, the webhook was either configured without a secret in GitHub or a proxy between GitHub and your server stripped the header. Check your GitHub webhook settings to confirm a secret is set, and review any reverse proxy or API gateway configuration for header filtering. See GitHub webhook not delivering for more network-level troubleshooting steps.
A faster debugging workflow
When signature verification fails, the standard approach is to add logging, redeploy, send a test event, read the logs, tweak, and repeat. Each cycle takes minutes. A faster path is to capture the real request outside your application, compare it to what your code receives, and pinpoint the discrepancy without redeploying.
HookNexus gives you a temporary endpoint URL you can add as a second webhook destination in GitHub — or as the primary one while debugging. Every delivery is captured and displayed in real time with the full headers, raw body, and exact X-Hub-Signature-256 value.
With the captured request visible in the HookNexus dashboard, you can:
- Copy the raw body and the signature header.
- Run your
computeSignaturefunction locally with the same secret and body. - Compare the output to the header value.
If the locally computed hash matches the header, your secret is correct and the problem is in how your framework delivers the body. If it does not match, the secret is wrong. Either way, you identify the root cause in one step instead of several deploy cycles.
For a deeper walkthrough of using this capture-and-compare approach, see GitHub webhook debugging. The HookNexus GitHub integration docs cover setup in detail.
Quick checklist
Use this checklist every time you set up or troubleshoot GitHub webhook signature verification:
- Webhook secret in GitHub settings matches the value in your application environment
- Raw request body is captured before any JSON parsing middleware runs
- HMAC is computed with
sha256and compared againstX-Hub-Signature-256 - Comparison uses
crypto.timingSafeEqual, not===or== - Buffer lengths are checked before calling
timingSafeEqual - Failed verification returns 401/403 and halts further processing
- Secret is stored in environment variables, not hard-coded in source
Frequently asked questions
What happens if I do not validate the webhook signature?
Without validation, anyone who knows or guesses your webhook endpoint URL can send a forged POST request with a fabricated payload. Your application would process it as if it came from GitHub. Depending on what your webhook handler does — triggering deployments, updating databases, sending notifications — this can lead to data corruption, unauthorized actions, or denial-of-service conditions. Always validate in production.
Can I use SHA1 instead of SHA256?
GitHub still sends the X-Hub-Signature header using HMAC-SHA1 for backward compatibility, so technically you can. However, SHA1 is considered cryptographically weak, and GitHub explicitly recommends SHA256. New integrations should always use X-Hub-Signature-256. If you are maintaining a legacy codebase that uses SHA1, plan a migration to SHA256 — it only requires changing the header name and the algorithm string in your HMAC call.
Why does my signature check pass locally but fail in production?
The most common reason is a difference in how the request body is handled. Local development servers and production servers may use different middleware stacks, reverse proxies, or body size limits. A proxy that decompresses gzip content, re-encodes characters, or truncates large payloads will alter the bytes your code sees, breaking the hash. Use HookNexus to capture the exact request that reaches your production URL and compare it byte-for-byte with what your application receives after proxy processing.
Do I need to verify signatures for ping events?
Yes. The ping event is the first delivery GitHub sends when you create a new webhook, and it is signed with the same secret. Verifying it confirms that your secret is configured correctly on both sides. Skipping verification for any event type creates an opening that an attacker could exploit.
Next steps
With signature verification in place, your GitHub webhook endpoint is protected against forged payloads. Here are logical next steps to harden and extend your integration:
- Read GitHub webhook debugging for a systematic approach to diagnosing delivery and processing failures beyond signature issues.
- Review GitHub webhook not delivering if you are receiving no deliveries at all, which is a different class of problem from signature mismatches.
- Browse the full GitHub webhook guide for an overview of event types, payload structures, and best practices.
- Set up a persistent HookNexus endpoint for your project so you always have a side channel to inspect raw deliveries without modifying application code.