A webhook endpoint should not process an event merely because the request contains believable JSON. For providers that sign deliveries with HMAC, verify the signature over the original request bytes before parsing the event or triggering a side effect. This guide uses GitHub's SHA-256 format; other providers can sign different inputs.

Check the provider's signing contract first

GitHub sends an X-Hub-Signature-256 header containing sha256= followed by a hexadecimal HMAC. The shared webhook secret is the key and the request body is the message. A plain SHA-256 hash is different: anyone who knows the body can calculate it without a secret.

A matching HMAC authenticates the signed bytes to someone holding the shared secret. It does not prove that the event is recent, that your application has not processed it before, or that every request header is authenticated.

Verify the raw body in Node.js

The function below accepts a Buffer containing the raw body. It validates the header format before converting hex and compares equal-length buffers with Node's timing-safe comparison function.

import { createHmac, timingSafeEqual } from 'node:crypto';

export function verifyGitHubSignature(rawBody, signature, secret) {
  if (!Buffer.isBuffer(rawBody)) {
    throw new TypeError('Expected the raw request body as a Buffer');
  }
  if (typeof secret !== 'string' || secret.length === 0) {
    throw new Error('Configure a webhook secret before accepting events');
  }
  if (typeof signature !== 'string' ||
      !/^sha256=[a-f0-9]{64}$/i.test(signature)) {
    return false;
  }

  const expected = createHmac('sha256', secret).update(rawBody).digest();
  const supplied = Buffer.from(signature.slice(7), 'hex');
  return supplied.length === expected.length &&
    timingSafeEqual(supplied, expected);
}

Reject an invalid signature before running business logic. A missing secret is a configuration failure, not an instruction to accept unsigned events. Provision the secret through your server's secret management mechanism and keep it out of source control and browser code.

The comparison helper does not make an entire HTTP handler timing-safe. Input processing, error handling and other surrounding operations need their own review. Prefer the provider's maintained verification library where one is available for your framework.

Why parsing and reserializing breaks verification

These payloads describe the same JSON value but contain different bytes:

{"action":"opened","number":1}

{ "action": "opened", "number": 1 }

Whitespace, key order, Unicode representation and trailing newlines can change a signature. Do not verify JSON.stringify(req.body) after a JSON middleware has consumed the request.

In Express, register a route-specific raw-body middleware before a global JSON parser can consume that route. For example, express.raw({ type: 'application/json', limit: '1mb' }) produces a Buffer for matching requests. Choose a body-size limit based on your provider's supported payload sizes and your own workload. After successful verification, parse the same saved bytes and handle malformed JSON explicitly.

Test against a published reference vector

GitHub publishes a test with the secret It's a Secret to Everybody and the payload Hello, World!. Those are public test values, not production credentials. The expected digest is:

757107ea0eb2509fc211221cce984b8a37570b6d7586c22c46f4379c8b043e17

Your verifier should accept that exact body and reject a body with one extra newline. Also test a missing header, a malformed hex string, a valid-length incorrect digest and the correct digest with the wrong secret.

Handle duplicate deliveries separately

Store delivery identifiers and processing state durably so a retry does not duplicate a payment, deployment or notification. GitHub retains the same X-GitHub-Delivery value when an existing delivery is redelivered. Use an atomic insert or uniqueness constraint, rather than an in-memory Set that disappears when a worker restarts.

Delivery IDs help with normal retry handling, but GitHub's body HMAC does not cover that header. Do not treat an unsigned delivery ID as a cryptographic freshness guarantee. Make business operations idempotent using stable, validated event data and application state as well. If another provider signs a timestamp, validate its documented signed input and time window instead of inventing a generic timestamp rule.

For GitHub specifically, failed deliveries are not automatically redelivered. Use its documented redelivery flow or a recovery job, and acknowledge events only after the work or durable queue entry is safely recorded.

Debug without exposing a secret

Log the delivery identifier, event type, verification result and processing outcome. Avoid logging shared secrets or full sensitive request bodies. For reproducible debugging, use a public test vector and a synthetic payload.

The JSON Formatter can help inspect a copy after verification, and the Diff Checker can compare readable payload text. The site's Hash Generator calculates unkeyed text hashes; it is not an HMAC verifier. Never substitute its SHA-256 output for the provider's signature check.

References