← All articles

Verifying GitHub Webhook Signatures

Webhooker Team 6 min read
Flat illustration of GitHub webhook signature verification: a commit graph sending an event through a blue node with the Webhooker pulse to two identical digests matched by an equals sign and a checkmark.

GitHub signs every webhook by computing an HMAC-SHA256 of the exact raw request body using your webhook secret, 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 headers, and the difference matters:

Webhooker verifies HMAC-SHA256 or SHA1 per source, so both GitHub headers are covered, but for any new integration you want SHA-256. When you add a GitHub source and pick the SHA-256 scheme, the gateway checks the signature before the event ever reaches your application.

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.

Verifying the signature correctly

The single most common 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, and it accounts for more mismatches than the other five combined.

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:

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 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.

Events and delivery ids

Two more headers tell you what arrived and how to track it, and a full GitHub delivery shows them in place alongside the rest of the request:

A quick note on the first delivery you will see: when you create a webhook, GitHub immediately sends a ping event to confirm the endpoint works. Handle it explicitly, return 200, and do not treat it as real repository activity.

Handling redeliveries

GitHub lets you redeliver past events from the repository or organization settings UI, and through the REST API. This is genuinely useful: if your endpoint was down or shipped a bug, you can replay the exact payloads instead of losing them. The catch is that a redelivery repeats the original event, and a redelivered event reuses the same X-GitHub-Delivery id.

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. This is the reliable way to dedupe GitHub redeliveries with the X-GitHub-Delivery id rather than trying to diff payloads. The storage pattern behind it, and how long to keep the keys around, is covered in idempotency keys for webhook consumers.

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. Once you key on the delivery id, that stops being something you have to worry about.

Common failures

Offloading verification to a gateway

Signature verification, raw-body handling, and idempotency are the kind of plumbing every service reinvents and gets subtly wrong. A gateway moves that work off your application’s hot path.

That is what a webhook gateway does: Webhooker gives each source its own ingest URL, checks the HMAC before accepting the payload, and returns a fast 200 OK while delivery happens out of band on a durable PostgreSQL queue. Attempts retry with exponential backoff behind a per-destination circuit breaker, failures land in a dead-letter queue you can inspect and replay, and every replay or redelivery is visible in the per-attempt history. You get signature verification and replay built in without wiring HMAC and dedupe into each handler yourself. When you are ready, receive GitHub events on a verified ingest URL and keep your own code focused on the event, not the transport.

This reflects how we run inbound webhooks in production at Webhooker: verify at the edge, store the raw event, and make every retry safe to repeat.

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. Only fall back to SHA-1 if you must support a client that cannot produce a SHA-256 signature.

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. In Webhooker, a failed delivery sits in the dead-letter queue and you replay it with one click or in bulk, with the redelivery recorded in history.

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.