# Verifying GitHub Webhook Signatures

> GitHub signs webhooks with X-Hub-Signature-256. Verify the HMAC against the raw body and handle redeliveries so you never process an event twice.

Source: https://webhooker.eu/blog/verify-github-webhook-signature
Last updated: 2026-09-18

GitHub signs every webhook by computing an HMAC-SHA256 of the exact raw request body with your GitHub webhook secret as the key, then sending the result in the `X-Hub-Signature-256` header as `sha256=<hex>`. To verify, compute the same HMAC over the unparsed bytes and compare it to the header with a constant-time check. Reject anything that does not match before you read the payload.

## How GitHub signs webhooks

When you add a webhook secret in a repository or organization, GitHub uses it as the key for a keyed hash of the request body. Every delivery then carries a signature header you can recompute and check.

There are two [signature headers](https://docs.github.com/en/webhooks/webhook-events-and-payloads#delivery-headers), and the difference matters:

- `X-Hub-Signature-256` holds an HMAC-SHA256 digest, formatted as `sha256=` followed by the hex digest. This is the one to verify.
- `X-Hub-Signature` holds a legacy HMAC-SHA1 digest, formatted as `sha1=<hex>`. GitHub still sends it for backward compatibility, but SHA-1 is weak ([practical collisions were demonstrated in 2017](https://shattered.io/)) and should not be your primary check. Bitbucket Cloud reuses the `X-Hub-Signature` name but puts a `sha256=` value in it; the [Git webhooks comparison](https://webhooker.eu/blog/git-webhooks) lists each platform’s scheme.

[Webhooker’s](https://webhooker.eu/#features) GitHub preset follows the same rule when you [add a GitHub source](https://docs.webhooker.eu/receive/verification/): it verifies `X-Hub-Signature-256` and ignores the SHA-1 header.

The secret is never sent over the wire. GitHub holds its copy, you hold yours, and the signature proves that whoever sent the request knew the shared secret. If an attacker forges a payload without it, the digest will not line up. A captured payload resent verbatim is a different matter: the signature still validates, which is [why a valid signature alone does not stop replays](https://webhooker.eu/blog/webhook-replay-attacks-timestamp-tolerance).

## Verifying the signature correctly

The usual mistake is verifying against a re-serialized body instead of the exact bytes GitHub sent. HMAC is computed over raw bytes, so a single re-ordered key or changed whitespace produces a completely different digest. Capture the raw body before any JSON parser touches it. It is the first of [six causes behind a failed signature check](https://webhooker.eu/blog/webhook-signature-verification-failed), and the one to rule out before looking at the secret.

Here is the sequence, independent of language:

1. Read the raw request body as bytes. Do not decode it to an object yet.
2. Read the `X-Hub-Signature-256` header. If it is missing, reject the request.
3. Compute `HMAC-SHA256(secret, raw_body)` and hex-encode it.
4. Prefix your result with `sha256=` so it matches the header format.
5. Compare the two strings with a constant-time comparison, not `==`.
6. On a match, parse the body and process the event. On a mismatch, return 401 and stop.

In pseudocode:

```text
raw = request.body_bytes
sig = request.header["X-Hub-Signature-256"]
expected = "sha256=" + hmac_sha256(secret, raw).hexdigest()
if not constant_time_equals(sig, expected):
    return 401
event = parse_json(raw)
```

Two details protect you. Constant-time comparison, such as Python’s [`hmac.compare_digest`](https://docs.python.org/3/library/hmac.html#hmac.compare_digest), stops an attacker from learning the correct signature byte by byte through timing differences. Verifying before parsing means a forged payload never reaches code that trusts it. GitHub documents the same approach in its [validating webhook deliveries guide](https://docs.github.com/en/webhooks/using-webhooks/validating-webhook-deliveries).

## Events and delivery ids

Two more headers tell you what arrived and how to track it, and [a full GitHub delivery](https://webhooker.eu/blog/how-do-webhooks-work) shows them in place alongside the rest of the request:

- `X-GitHub-Event` names the event type, such as `push`, `pull_request`, or `issues`. Route on this to decide what your handler does.
- `X-GitHub-Delivery` carries a unique UUID for that specific delivery. Store it. It is the key that keeps you from acting on the same event twice.

The first delivery you see will not be a repository event. When you create a webhook, GitHub immediately sends a [`ping` event](https://docs.github.com/en/webhooks/webhook-events-and-payloads#ping) to confirm the endpoint works. Verify it, return 200, and route it away from the handlers that act on repository changes.

## Handling redeliveries

GitHub [does not automatically redeliver failed deliveries](https://docs.github.com/en/webhooks/using-webhooks/handling-failed-webhook-deliveries). Redelivery is manual, from the [repository or organization settings UI](https://docs.github.com/en/webhooks/testing-and-troubleshooting-webhooks/redelivering-webhooks) or through the [REST API](https://docs.github.com/en/rest/repos/webhooks#redeliver-a-delivery-for-a-repository-webhook), and only covers deliveries from the past 3 days. If your endpoint was down or shipped a bug, that window is how you get the exact payloads back. A redelivery repeats the original event, though, and it reuses the same `X-GitHub-Delivery` id. The [GitHub webhooks guide](https://webhooker.eu/blog/github-webhooks-guide) covers the events, the 10-second timeout and the redelivery API.

That id is your idempotency key. Before processing, check whether you have already handled this delivery id; if you have, acknowledge with 200 and skip the work. Comparing payloads to spot a repeat is slower and less reliable than a lookup on the id. The storage pattern behind it, and how long to keep the keys around, is covered in [idempotency keys for webhook consumers](https://webhooker.eu/blog/webhook-idempotency-keys).

Design for at-least-once delivery. GitHub, and any serious gateway in front of it, can send the same event more than once, so your handler has to be safe to run twice. A unique constraint on the delivery id makes the second run insert nothing.

## Common failures

- Verifying `X-Hub-Signature` (SHA-1) when you meant `X-Hub-Signature-256`, or comparing a `sha256=` value against a `sha1=` header. Pick SHA-256 and read the matching header.
- Parsing the body before verifying. Once a framework decodes and re-encodes the JSON, the bytes change and your HMAC will never match. Grab the raw body first.
- Secret mismatch. A trailing newline, a copy-paste space, or a stale secret after rotation all break verification. Confirm the exact secret is set on both sides.
- Using `==` for the comparison. Use a constant-time equality function to avoid timing leaks.
- Treating the `ping` event as real activity, or returning a non-200 to it so GitHub reports the webhook as failing.
- Dropping the delivery id, then reprocessing a redelivery as if it were new.

If you cannot tell which of these you are hitting, catch one delivery in a [webhook tester](https://webhooker.eu/webhook-tester) first: seeing the real `X-Hub-Signature-256` value and the raw body next to each other usually settles it in seconds. Paste the secret there to [check the GitHub signature](https://webhooker.eu/webhook-tester?provider=github) and compare the expected digest with what your code computes.

## Offloading verification to a gateway

Raw-body capture and the HMAC check get reimplemented in every service that receives GitHub events, and small differences between those copies are where verification bugs come from. Moving the check in front of the application removes the copies. That is [what a webhook gateway does](https://webhooker.eu/blog/what-is-a-webhook-gateway): each source gets its own ingest URL, the HMAC is checked before the payload is accepted, and GitHub gets a quick 200 OK while delivery to your service runs later from a durable queue.

The delivery leg to your service can still fail. Attempts [retry with exponential backoff](https://webhooker.eu/blog/webhook-retries-exponential-backoff), usually behind a per-destination circuit breaker, and events that exhaust their attempts wait in a [dead-letter queue you can inspect and replay](https://webhooker.eu/blog/webhook-dead-letter-queue-replay). Each retry and each replay hands your handler an event it may already have processed.

So the dedupe check from the redelivery section stays in your code wherever the signature is verified. A manual redelivery from GitHub carries the original `X-GitHub-Delivery` id, a replay from a dead-letter queue repeats a delivery that may have half-succeeded, and a handler that records a stable event id under a unique constraint can take both without doing the work twice.

## Testing the check without GitHub

To fire a signed `push` or `pull_request` at a handler on `localhost`, without a repository and without a tunnel, use [webhook-mock-sender](https://webhooker.eu/tools/webhook-mock-sender): it computes `X-Hub-Signature-256` over the exact bytes it sends and includes `X-GitHub-Event` and `X-GitHub-Delivery` the way GitHub does. `--repeat 2` sends the same delivery ID twice, which is the redelivery case your dedupe logic is supposed to survive. To look at what GitHub itself sends instead, point a repository webhook at our self-hosted [webhook-tester](https://webhooker.eu/tools/webhook-tester).

## Frequently asked questions

### SHA-1 or SHA-256?

Use SHA-256. GitHub sends both `X-Hub-Signature` (SHA-1) and `X-Hub-Signature-256` (SHA-256) on every delivery, but SHA-1 exists only for backward compatibility with old integrations. SHA-1 is cryptographically weak, so verify the SHA-256 header and treat the SHA-1 one as legacy.

### How do I re-run a failed delivery?

From GitHub, open the webhook’s Recent Deliveries in repository or organization settings, select the delivery, and click Redeliver; the REST API exposes the same action. The event returns with its original `X-GitHub-Delivery` id, so dedupe on that id. GitHub only offers this for deliveries from the past 3 days; if a gateway sits in front of your service, a delivery that failed downstream can also be replayed from its dead-letter queue.

### What is the ping event?

`ping` is the first event GitHub sends when a webhook is created, a health check that confirms your endpoint is reachable and configured. Its `X-GitHub-Event` header reads `ping` rather than a repository action. Verify its signature like any other delivery, return 200 so GitHub marks the webhook active, and skip your normal event processing since it carries no repository change.
