GitHub webhook signature verification
GitHub sends `sha256=` plus a hex HMAC-SHA256 of the raw body in X-Hub-Signature-256. Paste that header, the body and the webhook secret to check it.
Last reviewed · scheme facts from the site's provider table · GitHub documentation
Check a GitHub signature
The exact rule
These are the facts this verifier implements, read from the same provider table that powers /providers. A mismatch here is a bug in the tool, not a preference.
- Signature header
- x-hub-signature-256 = sha256=hex
- Algorithm
- HMAC-SHA256
- Signed string
- raw body
- Encoding
- hex
- Timestamp / tolerance
- Not published
- Signing secret
- Webhook secret you set
How GitHub signs a webhook
The scheme is `X-Hub-Signature-256: sha256=<hex>`. GitHub computes HMAC-SHA256 with the webhook secret as the key and the raw request body as the message, and hex-encodes the digest. The `sha256=` prefix is part of the header, not part of the HMAC input: stripping it for the comparison is correct, and omitting the prefix check lets a `sha1=` header slip through as a mismatch.
Because the body alone is signed, the byte-for-byte body is the whole input. GitHub sends JSON, and a framework that parses and re-serialises it changes the bytes. A delivery's X-GitHub-Delivery header (a GUID) and X-GitHub-Event (the event name) are useful for logging and deduplication but do not affect the signature.
The key is the secret you set on the webhook, whether it lives on a repository, an organisation or a GitHub App. Different webhooks can use different secrets; verify with the one belonging to the webhook that points at your endpoint.
Which secret to use, and what the timestamp means
The secret is chosen by you when the webhook is created, in the repository or organisation settings, or in the GitHub App's webhook configuration. It is not a personal access token, an app private key or an installation token. If you did not set one, GitHub sends no signature header at all — a valid reason to reject the delivery.
GitHub's signature scheme has no timestamp header and the provider table this site publishes records a null tolerance. The checker therefore reports no timestamp, and replay protection, if needed, is your handler's own idempotency check on X-GitHub-Delivery.
The fastest end-to-end test is a ping: GitHub sends one when a webhook is created or when you press Redeliver on a delivery, and it is signed like every other event. A handler that accepts the ping with a 2xx is wired correctly, and the delivery detail in GitHub shows the exact response your endpoint returned. If the ping fails, compare the header and body here with the values GitHub shows for that delivery before changing any code — the mistake is usually a pasted secret or a parsed body, not the HMAC itself.
The raw body is the input
Use the raw body. In Next.js that is `await request.text()`; in Express, `express.raw({ type: "application/json" })`; in PHP, `file_get_contents("php://input")`. Parsing first and stringifying later is the common failure, and it usually shows up as signature mismatches only in production because a local curl replay used the exact bytes.
The legacy X-Hub-Signature header is SHA-1 and is not what this tool verifies. If you still see it, check both headers only if you have a reason; otherwise move the verification to X-Hub-Signature-256, as GitHub's current documentation recommends.
GitHub adds delivery metadata beside the signature: X-GitHub-Event names the event, X-GitHub-Delivery is a GUID for the delivery and X-GitHub-Hook-ID identifies the webhook. None of them is signed. They are still worth reading — the event name routes the payload, and the delivery GUID is the natural key for idempotency when a retry or a duplicated delivery reaches your handler.
Rotating a webhook secret is a single-value change on GitHub: after you save a new secret, deliveries are signed only with the new one. There is no dual-signing window, so change the receiver's secret first, then the webhook's, or accept a small window of failed deliveries. A signature mismatch immediately after a rotation is expected, not a bug in the verifier.
A sample signature header
The signature below is real for this sample body and the made-up secret above. Load a fresh sample in the tool to generate a self-consistent pair that passes verification.
Header(s)
{
"x-hub-signature-256": "sha256=3ed6a01913809fd7942324621c8a9173383ea2663aca39e0371a63c817545a07"
}Raw body
{"action":"opened","number":7,"repository":{"full_name":"example/repo"},"pull_request":{"id":1}}Example secret (not a real credential)
github_example_secret_not_real
Verify it in your own server
Three complete implementations of the same rule. Each reads the exact header(s), HMACs the raw body with the secret and compares in constant time; the comment at the top says where the raw body comes from in that runtime.
Node.js
import { createHmac, timingSafeEqual } from "node:crypto";
// rawBody: the exact bytes GitHub sent. signatureHeader: x-hub-signature-256.
export function verifyGithubSignature(rawBody, signatureHeader, secret) {
if (!signatureHeader.startsWith("sha256=")) return false;
const expected = createHmac("sha256", secret).update(rawBody).digest("hex");
const provided = signatureHeader.slice("sha256=".length);
const expectedBuffer = Buffer.from(expected);
const providedBuffer = Buffer.from(provided);
return (
expectedBuffer.length === providedBuffer.length &&
timingSafeEqual(expectedBuffer, providedBuffer)
);
}Python
import hashlib
import hmac
def verify_github_signature(raw_body: bytes, signature_header: str, secret: str) -> bool:
if not signature_header.startswith("sha256="):
return False
expected = hmac.new(secret.encode(), raw_body, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, signature_header[len("sha256="):])PHP
<?php
// $rawBody = file_get_contents("php://input");
function verify_github_signature(string $rawBody, string $signatureHeader, string $secret): bool {
if (!str_starts_with($signatureHeader, "sha256=")) {
return false;
}
$expected = hash_hmac("sha256", $rawBody, $secret);
return hash_equals($expected, substr($signatureHeader, 7));
}Common GitHub failures
Each row is a failure seen while verifying this scheme, not a generic webhook checklist. The symptom column is what the checker or your handler reports; the fix is the change that resolves it.
| Mistake | Symptom | Fix |
|---|---|---|
| Verifying X-Hub-Signature (SHA-1) only | Current deliveries fail while an old test payload passes. | Verify X-Hub-Signature-256, the SHA-256 header GitHub sends on current deliveries. |
| Missing `sha256=` prefix check | The hex comparison fails on every delivery; or a sha1 header is treated as a malformed sha256 value. | Require the prefix, then compare the hex after it. |
| Body parsed before verification | The checker passes a pasted body but the handler always fails. | Read the raw body once; verify the string, then JSON.parse it. |
| Using a personal access token as the secret | HMAC never matches; the token works for the API so it looks like the right credential. | Use the webhook's secret string, set in the repository, organisation or App webhook settings. |
| Wrong webhook's secret on a repo with several hooks | Some deliveries verify and others fail even though the event mapping looks right. | Match the hook id/URL to its secret before comparing. |
| Comparing uppercase hex against lowercase | Fails for some implementations and passes for others. | Normalise both sides to lowercase (or compare case-insensitively) before the constant-time compare. |
| Treating the signature as covering headers | Attempts to include X-GitHub-Event or X-GitHub-Delivery in the signed string never match. | The HMAC covers the raw body only. |
| No secret configured, handler expects a signature | Deliveries arrive without X-Hub-Signature-256 and are rejected by the handler. | Set a webhook secret; without one GitHub signs nothing. |
GitHub signature FAQ
- Should I verify X-Hub-Signature or X-Hub-Signature-256?
- X-Hub-Signature-256. GitHub's current documentation leads with the SHA-256 header; the old header is SHA-1 and weaker.
- Where is the webhook secret?
- You set it when creating the webhook: repository settings → Webhooks, organisation settings → Webhooks, or the GitHub App's webhook settings. GitHub never shows it back to you.
- Why include the sha256= prefix in the check?
- It identifies the algorithm and prevents a legacy sha1= value from being compared as if it were SHA-256. Strip it only after confirming it.
- Does GitHub sign ping events?
- Yes — GitHub signs deliveries, including the ping sent when a webhook is created, so a successful ping verification is a good first test of your handler.
- Is there a replay window?
- Not in the signature itself; there is no timestamp header in this scheme. Deduplicate on X-GitHub-Delivery if a repeated delivery could run your handler twice.
- Can I verify GitHub signatures without a library?
- Yes. HMAC-SHA256 of the raw body, hex, constant-time compare — the three snippets above are complete.
What this tool cannot tell you
- It cannot prove GitHub sent the delivery: a matching HMAC only proves the body was signed with the webhook secret.
- It cannot verify the legacy SHA-1 X-Hub-Signature header; this tool checks the SHA-256 header only.
- It cannot replay-protect: there is no timestamp in the scheme. Deduplicate on X-GitHub-Delivery yourself.
- It cannot recover a body already consumed by a parser.
Related
Other schemes
Next checks
A valid signature does not mean the delivery succeeded. Time your handler against each sender's response budget, and keep the event so a failure can be replayed.