Webhook Guides

GitHub Webhook Signature Validation

GitHub webhook signature validation should happen before payload routing, deploy triggers, pull request automation, retry, or replay. The check is narrow and important: verify that X-Hub-Signature-256 matches the exact raw request body GitHub sent.

FastHook can perform this check at the source with HMAC auth. The configuration below matches the current backend behavior: use auth_type: "HMAC", read x-hub-signature-256, prepend the sha256= prefix, and do not add a timestamp header for GitHub signatures.

Signature boundary model

There are two different signatures that often get mixed together. GitHub signs the provider-to-FastHook request. FastHook can optionally sign the FastHook-to-destination delivery. They use different headers, prefixes, and signed payload strings.

GitHub webhook signature validation boundary showing GitHub X-Hub-Signature-256 at source ingress and FastHook destination signatures on outbound delivery.
Verify GitHub at ingress with raw-body HMAC. Use FastHook destination signatures only for the downstream delivery from FastHook to your receiver.
BoundaryHeaderPrefixSigned valueSecret
GitHub -> FastHookX-Hub-Signature-256sha256=<hex>raw request bodyGitHub webhook secret
FastHook -> destinationx-fasthook-signaturev1=<hex>x-fasthook-timestamp.rawBodyFastHook destination signing secret
Tracing onlyX-GitHub-DeliveryGUIDnot signed by itselfUse for logs and idempotency evidence
Routing onlyX-GitHub-Eventpush / pull_requestnot a trust boundaryUse after signature validation

GitHub signed request shape

When a GitHub webhook secret is configured, GitHub includes X-Hub-Signature-256. The digest is HMAC-SHA256 over the request body and is prefixed with sha256=. X-GitHub-Event and X-GitHub-Delivery are still valuable, but they are routing and tracing evidence rather than authentication.

GitHub signed request
POST /github HTTP/1.1
Host: hook-xxxxxx.fasthook.io
Content-Type: application/json
User-Agent: GitHub-Hookshot/abc123
X-GitHub-Event: push
X-GitHub-Delivery: 72d3162e-cc78-11ec-9d64-0242ac120002
X-Hub-Signature-256: sha256=<hmac-sha256-hex>

{ "ref": "refs/heads/main", "repository": { "full_name": "acme/payments-api" } }

Verify the raw body in Node.js

The most common signature bug is verifying parsed JSON instead of the original body string. Parse the payload only after signature validation succeeds. Even changing whitespace or key order changes the digest.

Node.js signature verification
import crypto from "node:crypto";

export function verifyGitHubSignature({ rawBody, signature, secret }) {
  if (!signature?.startsWith("sha256=")) return false;

  const expected = "sha256=" + crypto
    .createHmac("sha256", secret)
    .update(rawBody)
    .digest("hex");

  const actualBytes = Buffer.from(signature);
  const expectedBytes = Buffer.from(expected);

  return actualBytes.length === expectedBytes.length &&
    crypto.timingSafeEqual(actualBytes, expectedBytes);
}

Verify the raw body with Web Crypto

Cloudflare Workers and other edge runtimes can use Web Crypto directly. This mirrors the source HMAC shape that FastHook uses for GitHub: no timestamp input, SHA-256 HMAC, and a sha256= header prefix.

Web Crypto verification
const encoder = new TextEncoder();

function hexToBytes(hex) {
  if (hex.length % 2 !== 0) return null;

  const bytes = new Uint8Array(hex.length / 2);
  for (let index = 0; index < hex.length; index += 2) {
    const value = Number.parseInt(hex.slice(index, index + 2), 16);
    if (Number.isNaN(value)) return null;
    bytes[index / 2] = value;
  }

  return bytes;
}

export async function verifyGitHubSignature(secret, header, rawBody) {
  if (!header?.startsWith("sha256=")) return false;

  const signatureBytes = hexToBytes(header.slice("sha256=".length));
  if (!signatureBytes) return false;

  const key = await crypto.subtle.importKey(
    "raw",
    encoder.encode(secret),
    { name: "HMAC", hash: "SHA-256" },
    false,
    ["verify"]
  );

  return crypto.subtle.verify(
    "HMAC",
    key,
    signatureBytes,
    encoder.encode(rawBody)
  );
}

Configure FastHook source HMAC for GitHub

The FastHook backend checks source HMAC auth against the raw body. For GitHub, configure the GitHub webhook secret, the x-hub-signature-256 signature header, and the sha256= prefix. Leavetimestamp_header out because GitHub does not sign timestamp.body.

Create GitHub source
curl -X POST "https://api.fasthook.io/v1/sources" \
  -H "Authorization: Bearer fhp_xxx" \
  -H "x-team-id: tm_3b5335b627084a838b" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "GitHub signed source",
    "description": "GitHub webhooks verified with X-Hub-Signature-256",
    "type": "WEBHOOK",
    "status": "enabled",
    "config": {
      "auth_type": "HMAC",
      "auth": {
        "secret": "github_webhook_secret",
        "signature_header": "x-hub-signature-256",
        "prefix": "sha256="
      },
      "allowed_http_methods": ["POST"],
      "custom_response": {
        "content_type": "json",
        "body": "{\"ok\":true}"
      }
    }
  }'

Send a signed smoke test

This local test signs the exact string it sends as the HTTP body. If you compute the signature on one string and send another, FastHook should reject the request.

Signed GitHub test
import crypto from "node:crypto";

const sourceUrl = process.env.FASTHOOK_GITHUB_SOURCE_URL;
const secret = process.env.GITHUB_WEBHOOK_SECRET;

const rawBody = JSON.stringify({
  ref: "refs/heads/main",
  repository: {
    full_name: "acme/payments-api"
  },
  head_commit: {
    id: "5f6b7a8c9d0e1f23456789abcdef0123456789ab",
    message: "Add checkout webhook retry guard"
  },
  sender: {
    login: "dev-user"
  }
});

const signature = "sha256=" + crypto
  .createHmac("sha256", secret)
  .update(rawBody)
  .digest("hex");

const response = await fetch(sourceUrl, {
  method: "POST",
  headers: {
    "content-type": "application/json",
    "user-agent": "GitHub-Hookshot/test",
    "x-github-event": "push",
    "x-github-delivery": crypto.randomUUID(),
    "x-hub-signature-256": signature
  },
  body: rawBody
});

console.log(response.status, await response.text());

Debug rejected signatures

A rejected request is useful evidence. It tells you the problem happened before routing and destination delivery. Start with missing headers, wrong secrets, altered bodies, and legacy SHA-1 configuration before checking connection filters.

GitHub webhook signature validation debug runbook for missing headers, wrong secret, body mutation, legacy SHA-1, accepted requests, filters, and FastHook destination signatures.
Rejected requests answer source-auth questions. Accepted requests move the investigation to filters, events, destination attempts, retries, and replay.
SymptomLikely causeFix
Missing signature headerGitHub webhook has no secret, the wrong header name is configured, or a proxy stripped the header.Configure a GitHub secret and verify FastHook source auth uses x-hub-signature-256.
Signature mismatchWrong secret, body changed before verification, missing sha256= prefix, or legacy SHA-1 header was used.Sign the exact raw body with the same secret and HMAC-SHA256.
Valid in local code, rejected in FastHookThe local test signed a different string from the body sent over HTTP.Log the raw payload string before signing and send that exact string as the request body.
Valid at source, invalid at destinationThe downstream payload was transformed, so the original GitHub signature no longer matches that delivered body.Use FastHook destination signatures for the FastHook-to-receiver hop.
Receiver trusts event headers onlyX-GitHub-Event and User-Agent are useful diagnostics, but they do not prove authenticity.Verify the signature before routing, deploying, or changing state.

Find rejected source-auth requests

Inspect rejected requests
curl -G "https://api.fasthook.io/v1/requests" \
  -H "Authorization: Bearer fhp_xxx" \
  -H "x-team-id: tm_3b5335b627084a838b" \
  --data-urlencode "source_id=src_q6z62b6py5o79b" \
  --data-urlencode "status=rejected" \
  --data-urlencode "from=now-24h" \
  --data-urlencode "to=now" \
  --data-urlencode "limit=20" \
  --data-urlencode "include=data"
Rejected request JSON
{
  "id": "req_7Jm4Q8",
  "status": "rejected",
  "verified": false,
  "method": "POST",
  "path": "/",
  "rejection_cause": "SOURCE_AUTH_FAILED",
  "data": {
    "body": null,
    "headers": {
      "content-type": "application/json",
      "x-github-event": "push",
      "x-github-delivery": "72d3162e-cc78-11ec-9d64-0242ac120002",
      "x-hub-signature-256": "sha256=<mismatched-signature>"
    },
    "method": "POST",
    "path": "/"
  }
}

Find accepted and verified requests

Inspect verified requests
curl -G "https://api.fasthook.io/v1/requests" \
  -H "Authorization: Bearer fhp_xxx" \
  -H "x-team-id: tm_3b5335b627084a838b" \
  --data-urlencode "source_id=src_q6z62b6py5o79b" \
  --data-urlencode "status=accepted" \
  --data-urlencode "verified=true" \
  --data-urlencode "from=now-24h" \
  --data-urlencode "to=now" \
  --data-urlencode "limit=20" \
  --data-urlencode "include=data" \
  --data-urlencode "q=72d3162e-cc78"

Route only after validation

Once the request is accepted, use headers and body fields for routing. A common pattern is event-family filtering by x-github-event, followed by repository, branch, pull request base branch, or action checks in the body.

Post-validation filter
{
  "type": "filter",
  "headers": {
    "x-github-event": {
      "$in": [
        "push",
        "pull_request"
      ]
    }
  },
  "body": {
    "$or": [
      {
        "ref": "refs/heads/main"
      },
      {
        "pull_request": {
          "base": {
            "ref": "main"
          }
        }
      }
    ]
  }
}

Use FastHook signatures for destination delivery

If your receiver needs to prove that a delivery came from FastHook, enable FastHook destination signatures. This is separate from GitHub validation. FastHook signs the outbound body with x-fasthook-timestamp and x-fasthook-signature using a v1= prefix.

Verify FastHook destination signature
import crypto from "node:crypto";

export function verifyFastHookDestinationSignature({
  rawBody,
  timestamp,
  signature,
  signingSecret
}) {
  const timestampSeconds = Number(timestamp);
  const nowSeconds = Math.floor(Date.now() / 1000);

  if (!Number.isSafeInteger(timestampSeconds)) return false;
  if (Math.abs(nowSeconds - timestampSeconds) > 5 * 60) return false;
  if (!signature?.startsWith("v1=")) return false;

  const expected = "v1=" + crypto
    .createHmac("sha256", signingSecret)
    .update(timestamp + "." + rawBody)
    .digest("hex");

  const actualBytes = Buffer.from(signature);
  const expectedBytes = Buffer.from(expected);

  return actualBytes.length === expectedBytes.length &&
    crypto.timingSafeEqual(actualBytes, expectedBytes);
}

// FastHook sends:
// x-fasthook-timestamp: Unix timestamp in seconds
// x-fasthook-signature: v1=<hex-hmac-sha256>

GitHub signature validation FAQ

What does X-Hub-Signature-256 verify?

X-Hub-Signature-256 verifies the exact raw GitHub webhook body with HMAC-SHA256 and the webhook secret configured in GitHub. The header value starts with sha256=.

Does GitHub include a timestamp in its webhook signature?

No. GitHub X-Hub-Signature-256 is computed over the request body, not timestamp.body. Do not configure a timestamp header for a GitHub source HMAC check.

Can FastHook verify GitHub webhook signatures at ingress?

Yes. FastHook source HMAC auth verifies a configurable HMAC-SHA256 signature over the raw body. For GitHub, configure signature_header as x-hub-signature-256 and prefix as sha256=.

Is a FastHook destination signature the same as a GitHub signature?

No. GitHub signs the provider request with X-Hub-Signature-256 and sha256=. FastHook destination signatures use x-fasthook-timestamp and x-fasthook-signature with v1= over timestamp.rawBody.

GitHub documentation references

GitHub documents X-Hub-Signature-256, the sha256= prefix, raw payload validation, and timing-safe comparison in validating webhook deliveries. GitHub lists delivery headers such as X-GitHub-Event, X-GitHub-Delivery, and X-Hub-Signature-256 in its webhook events and payloads reference.

Related guides