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.
| Boundary | Header | Prefix | Signed value | Secret |
|---|---|---|---|---|
| GitHub -> FastHook | X-Hub-Signature-256 | sha256=<hex> | raw request body | GitHub webhook secret |
| FastHook -> destination | x-fasthook-signature | v1=<hex> | x-fasthook-timestamp.rawBody | FastHook destination signing secret |
| Tracing only | X-GitHub-Delivery | GUID | not signed by itself | Use for logs and idempotency evidence |
| Routing only | X-GitHub-Event | push / pull_request | not a trust boundary | Use 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.
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.
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.
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.
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.
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.
| Symptom | Likely cause | Fix |
|---|---|---|
| Missing signature header | GitHub 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 mismatch | Wrong 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 FastHook | The 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 destination | The 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 only | X-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
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"{
"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
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.
{
"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.
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.