How do you verify a GitHub webhook payload is authentic using X-Hub-Signature-256?
answer
- shared secret, not a password check
- keyed hash over the body
- raw bytes, before JSON parsing
- hex HMAC-SHA256 with a sha256= prefix
- compare with a constant-time function
basics
~20 sGitHub signs each webhook with the secret you configured: X-Hub-Signature-256 holds a hex HMAC-SHA256 of the raw request body. Recompute that HMAC over the exact bytes received and compare it in constant time, rejecting the request when it differs.
solid answer
~50 sWhen a webhook has a secret configured, GitHub computes `HMAC-SHA256(secret, raw_request_body)` and sends it hex-encoded in the `X-Hub-Signature-256` header, prefixed with `sha256=`. On the receiving side you read the **raw bytes** of the body before any JSON parsing or middleware re-serialisation, compute the same HMAC with the same secret, and compare the two values using a constant-time comparison such as `hmac.compare_digest` in Python or `crypto.timingSafeEqual` in Node — an ordinary `==` leaks information through timing. If the header is missing, or the digests differ, return 4xx and process nothing. Three details bite people: the signature covers the body exactly as transmitted, so a framework that parses and re-encodes JSON will produce a different digest; the header is absent entirely when no secret is set, so "no signature" must mean reject, not allow; and the older SHA-1 `X-Hub-Signature` header exists only for legacy consumers and should not be the one you validate.
code
javascript · 24 linesconst crypto = require("crypto");
function verify(rawBody, headerValue, secret) {
if (!headerValue) return false;
const expected =
"sha256=" + crypto.createHmac("sha256", secret).update(rawBody).digest("hex");
const a = Buffer.from(expected, "utf8");
const b = Buffer.from(headerValue, "utf8");
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
app.post(
"/hooks/github",
express.raw({ type: "application/json" }),
(req, res) => {
if (!verify(req.body, req.get("X-Hub-Signature-256"), process.env.WEBHOOK_SECRET)) {
return res.status(401).send("bad signature");
}
const event = req.get("X-GitHub-Event");
const delivery = req.get("X-GitHub-Delivery");
enqueue(event, delivery, JSON.parse(req.body.toString("utf8")));
res.status(202).send("queued");
}
);go deeper
Know that webhooks carry a signature header and that you must check it against a shared secret before doing anything with the payload. Naming X-Hub-Signature-256 and HMAC-SHA256 is enough at this level.
Be able to write the verification: raw body, HMAC-SHA256 keyed with the secret, hex encoding with the sha256= prefix, constant-time comparison, reject on mismatch or missing header.
Show what verification does not cover — replay and authorisation — and pair it with delivery-GUID deduplication, payload field checks before acting, fail-closed handling, and a dual-accept secret rotation plan.
Own the standard across many receivers: where secrets live and how often they rotate, whether one shared endpoint or many, how verification failures are alerted on, and how the organisation proves the control is actually in place.
## The threat A webhook endpoint is a public URL that accepts POSTs and performs privileged work — deploying, merging, posting to Slack, writing to a database. Anyone who learns the URL can POST a hand-crafted payload claiming a push landed on `main`. Nothing about the request proves it came from GitHub: source IP ranges change and are a weak control on their own, and TLS proves who *you* are, not who the caller is. The signature is the control that makes the payload trustworthy. ## How the signature is produced When you configure a webhook you can set a **secret** — a random string shared between GitHub and your receiver. For every delivery, GitHub computes a keyed hash of the request body: `signature = HMAC-SHA256(key = secret, message = raw request body bytes)` and sends it hex-encoded in the header `X-Hub-Signature-256`, with the algorithm as a prefix: `X-Hub-Signature-256: sha256=d57c68ca6f92289e...` HMAC is a message authentication code, not an encryption: it proves the sender knew the secret and that the body was not altered in transit. It does not hide the payload — the body is plaintext JSON, protected only by TLS. GitHub also sends `X-Hub-Signature`, the same construction with SHA-1, for older integrations. Validate the SHA-256 header; the SHA-1 one exists for compatibility. ## How to verify it 1. **Capture the raw body.** This is the single most common bug. Many web frameworks parse JSON before your handler runs, and if you re-serialise the parsed object you get semantically identical but byte-different JSON — different key order, different whitespace, different unicode escaping — and the HMAC will not match. Express needs `express.json({ verify: ... })` or a raw body parser; Rails, Django, Spring and others all have an equivalent escape hatch. 2. **Recompute the HMAC** with the same secret and SHA-256, hex-encode it, and prefix `sha256=`. 3. **Compare in constant time.** A byte-by-byte comparison that returns early on the first mismatch takes measurably longer for a signature that shares a longer prefix with the real one, which in principle lets an attacker discover the digest one byte at a time. Use `hmac.compare_digest`, `crypto.timingSafeEqual`, `MessageDigest.isEqual`, or your language's equivalent, and make sure both inputs have the same length before comparing so the primitive does not throw. 4. **Fail closed.** Missing header, wrong length, mismatch — return 401 or 403 and do no work. Do not log the received signature at a level where it becomes a searchable artifact, and never log the secret. 5. **Only then parse the JSON** and dispatch on `X-GitHub-Event`. ## What signature verification does and does not give you It gives you **authenticity** (the sender holds the secret) and **integrity** (the bytes were not modified). It does not give you: - **Freshness.** A captured delivery can be replayed against your endpoint later and the signature still validates. If replay matters, track the `X-GitHub-Delivery` GUID and ignore GUIDs you have already processed — which you want anyway for idempotency. - **Confidentiality.** Always terminate TLS; the payload itself is readable. - **Authorisation.** A valid signature says GitHub sent this; it does not say the event should cause the action. Check the repository, the branch, and the sender in the payload before acting — for example, refuse to deploy from a payload whose `repository.full_name` is not one you own. ## Operational practice Use a long random secret, distinct per webhook where practical, stored as a secret in your platform rather than in source. Rotating it is a brief window of failures unless you accept either the old or the new secret during the change: verify against both, then drop the old one. If you are receiving events for a GitHub App, the secret is set once on the app and covers every installation, which makes rotation an app-wide event worth scheduling. Finally, verification is only the first gate. After it passes, respond 2xx quickly and process asynchronously, deduplicate on the delivery GUID, and treat every field in the payload as data to validate rather than commands to execute. ## Interview red line Saying "we allowlist GitHub's IP ranges instead" is not a substitute answer. IP allowlisting is a defence-in-depth measure that must be maintained as the published ranges change and proves nothing about payload integrity. The signature is the mechanism.
- Why must the HMAC be computed over the raw bytes rather than the parsed JSON?Because HMAC is over exact bytes. Parsing and re-serialising JSON can change key order, whitespace, number formatting, or unicode escaping while keeping the same meaning, and any of those produces a different digest. Frameworks that eagerly parse the body must be configured to keep the original buffer, or verification will fail intermittently and mysteriously.
- A valid signature arrives twice with the same payload. Is that an attack?Usually not — deliveries can be replayed by a manual redelivery, and networks duplicate requests. The signature proves authenticity, not freshness, so a captured request can also be replayed by an attacker. Deduplicate on the X-GitHub-Delivery GUID and make handlers idempotent; that covers both the benign and the malicious case.
- How do you rotate a webhook secret without dropping deliveries?Temporarily accept either secret: verify against the new one, and on mismatch verify against the old one. Update the secret in the GitHub webhook configuration, confirm from the delivery log that new deliveries validate against the new secret, then remove the old one. Without the dual-accept window, every in-flight delivery during the change fails.
- Is allowlisting GitHub's IP ranges a valid alternative?No, it is defence in depth at best. Published ranges change and must be re-fetched, a shared egress or proxy can weaken the check, and an IP match says nothing about whether the body was altered. Verify the signature; use network controls only as an additional layer.
saying these in an interview costs you the question
- Compares signatures with == instead of constant time
- Computes the HMAC over re-serialised JSON
- Treats a missing signature header as acceptable
- Says TLS alone proves the request came from GitHub
- Thinks the signature encrypts or hides the payload