Validating a GitHub webhook means solving three separate problems: confirming that the delivery came from GitHub and was not modified, preventing the same delivery from producing repeated effects, and responding quickly enough that slow processing does not become a delivery failure. A practical design combines HMAC over the original request body, deduplication with X-GitHub-Delivery, persistent idempotent state, and a queue that removes heavy work from the HTTP response path.
What each mechanism solves
| Mechanism | Problem it solves | Implementation point |
|---|---|---|
| HMAC-SHA256 | Delivery authenticity and integrity | Recompute the signature with the secret and compare it with X-Hub-Signature-256 |
| X-GitHub-Delivery | Delivery identity and replay protection | Persist the GUID and reject or absorb repeats according to state |
| Idempotency | Prevent duplicated business effects | Use a unique key, transaction, or operation that can be safely repeated |
| Queue | Avoid endpoint timeouts | Validate, record, enqueue, and return 2XX quickly |
1. Validate the HMAC before interpreting the JSON
When a webhook has a secret configured, GitHub sends X-Hub-Signature-256. The signature starts with sha256= and is computed with HMAC-SHA256, using the secret as the key and the payload contents as the message. GitHub's documentation recommends validating the signature before continuing with processing.
The detail that most often causes failures is the body used in the calculation. The signature depends on the received contents. If a framework parses JSON and later serializes it again, whitespace, property order, or encoding can change. Capture the original request body and calculate the HMAC before any transformation. GitHub's own Ruby example reads request.body before parsing, and the TypeScript example reads the request text before verification.
Verifiable Node.js example
const expected = "sha256=" + crypto.createHmac("sha256", secret).update(rawBody).digest("hex");
const a = Buffer.from(expected, "utf8"); const b = Buffer.from(signatureHeader || "", "utf8"); const valid = a.length === b.length && crypto.timingSafeEqual(a, b);
The length check before timingSafeEqual is required because the Node.js API expects buffers of equal length. If the header is missing, uses another prefix, or does not match the computed HMAC, treat the delivery as unauthenticated and do not let it reach business logic.
GitHub also publishes an official test vector: with the secret It's a Secret to Everybody and the payload Hello, World!, the expected X-Hub-Signature-256 value starts with sha256=757107ea.... This is useful for verifying your implementation before connecting the endpoint to a real webhook.
2. Use X-GitHub-Delivery for replay protection and deduplication
The X-GitHub-Delivery header is a GUID that identifies the delivery. GitHub recommends using it to protect the endpoint against replay attacks. One detail matters greatly: when a delivery is intentionally redelivered, the same X-GitHub-Delivery value is preserved. The identifier therefore helps both with unwanted duplicates and with recognizing a legitimate retry of the same delivery.
Model state, not just a list of blocked IDs
A receipts table can use delivery_id as a unique key and store fields such as status, received_at, attempt_count, and last_error. A robust flow can use these rules:
- If the GUID has never been seen, create a record in received state and enqueue the work.
- If the GUID is already completed, return 2XX without repeating the business effect.
- If the GUID is processing, do not create a second concurrent worker for the same delivery.
- If the GUID is failed, a redelivery can start a controlled new attempt without creating two simultaneous operations.
This design is safer than placing the GUID into a permanent blacklist at first contact. A delivery can be received successfully and then fail later in the queue. Because redelivery keeps the same GUID, blocking it forever would also block legitimate recovery.
3. Make the business operation idempotent
Deduplicating the GUID reduces repeats, but it does not replace idempotency. Failure windows exist where a worker may complete an operation and crash before marking the receipt as completed. On the next attempt, the same job can appear again.
The strongest protection lives close to the business effect. Examples include a unique (delivery_id, operation) key, writing the event and the state change in the same transaction, or using a natural domain key that prevents the same mutation twice. For outbound integrations, keep an idempotency key associated with the requested effect whenever the destination API supports one.
4. Validate, record, enqueue, and respond
GitHub recommends returning a 2XX response within 10 seconds. If the endpoint takes longer, GitHub terminates the connection and considers the delivery a failure. That is why external API calls, report generation, or other expensive work should not normally happen synchronously before the response.
- Read the original body and required headers.
- Validate X-Hub-Signature-256.
- Read X-GitHub-Delivery and X-GitHub-Event.
- Record the delivery atomically while enforcing the GUID unique key.
- Enqueue only the work allowed by the current state.
- Return 2XX.
- In the worker, execute idempotent business logic and update the state to completed or failed.
The X-GitHub-Event header identifies the event type. GitHub also recommends checking the event type and, when present, the payload action before processing. This keeps a generic endpoint from applying the wrong handler to an unexpected event.
5. Intentional redelivery must be part of the design
GitHub does not automatically redeliver failed deliveries. An operator can request redelivery through the interface or API, and GitHub's documentation allows recent deliveries from the past three days to be redelivered. Because redelivery keeps the same GUID, the consumer must use persisted state to decide whether work remains or the delivery has already completed.
A safe pattern is: completed returns 2XX without repeating the effect; failed allows a controlled new attempt; processing requires a lock, lease, or another concurrency guard; stale records stuck in processing can be recovered using a clearly defined operational timeout.
Reference flow
| Stage | Check | Result |
|---|---|---|
| Ingress | HTTPS, expected method, and size limits | Request accepted for authentication |
| Signature | HMAC-SHA256 over the original body and timing-safe comparison | Origin and integrity validated |
| Identity | X-GitHub-Delivery | GUID associated with persistent state |
| Routing | X-GitHub-Event and action | Correct handler selected |
| Queue | Idempotent insert | Fast 2XX response |
| Worker | Transaction, unique key, or idempotency key | Business effect occurs at most once from a logical perspective |
Common mistakes
- Computing the HMAC over reserialized JSON instead of the received body.
- Comparing the signature with == instead of an appropriate timing-safe routine.
- Trusting only source IP and skipping the secret. An IP allow list can be an additional layer, not a replacement for HMAC.
- Using only local memory for deduplication in a multi-instance application.
- Marking a GUID as processed before the business effect really completes.
- Returning 2XX before durably recording the work that still needs processing.
- Treating every repetition of the same GUID as an attack and thereby blocking a legitimate redelivery of a prior failed attempt.
Implementation checklist
- High-entropy random secret stored outside source code.
- X-Hub-Signature-256 validated before parsing and business logic.
- Original request body preserved for the HMAC calculation.
- Timing-safe signature comparison.
- X-GitHub-Delivery enforced with a unique constraint and persistent state.
- Handler selected using X-GitHub-Event and action.
- Durable queue and 2XX response within the operational limit.
- Idempotent worker operation.
- Clear policy for processing, failed, and completed states.
- Redelivery procedure and failure observability.




