A webhook signature is a value, sent in a request header, that proves a webhook really came from the provider and that its body wasn't changed on the way. The provider computes it from the raw body (often plus a timestamp) and a shared webhook secret, almost always with HMAC-SHA256. Your endpoint recomputes it with the same secret and rejects the request if the values don't match.
POST /webhooks/github HTTP/1.1
X-GitHub-Event: push
X-Hub-Signature-256: sha256=3b1f6e4c0a5d8c3f0a3d5e2b1c4f9a7e8d6c5b4a3f2e1d0c9b8a7f6e5d4c3b2a
Content-Type: application/json
{"ref":"refs/heads/main", ...}
How webhook signature verification works
- You and the provider share a secret, shown when you create the webhook.
- For each delivery, the provider builds a signed string: the raw body, or a timestamp and the body joined together.
- It computes
HMAC-SHA256(secret, signed_string)and sends the result, hex or base64 encoded, in a header. - Your endpoint builds the same string from the bytes it received, computes the HMAC, and compares in constant time.
- If the provider includes a timestamp, you also reject requests older than a few minutes, which stops replay attacks.
import crypto from 'node:crypto'
function verifyGithub(rawBody, header, secret) {
const expected = 'sha256=' + crypto.createHmac('sha256', secret).update(rawBody).digest('hex')
const a = Buffer.from(expected), b = Buffer.from(header || '')
return a.length === b.length && crypto.timingSafeEqual(a, b)
}
Signature formats by provider
| Provider | Header | Signed string | Encoding |
|---|---|---|---|
| GitHub | X-Hub-Signature-256: sha256=... | raw body | hex |
| Stripe | Stripe-Signature: t=...,v1=... | {t}.{body} | hex |
| Shopify | X-Shopify-Hmac-Sha256 | raw body | base64 |
| Slack | X-Slack-Signature: v0=... with X-Slack-Request-Timestamp | v0:{timestamp}:{body} | hex |
| Standard Webhooks | webhook-signature: v1,... with webhook-id, webhook-timestamp | {id}.{timestamp}.{body} | base64 |
Each has its own verifier page: Stripe, GitHub, Shopify, Slack and Standard Webhooks.
Common mistakes
- Verifying a parsed body.
JSON.stringify(JSON.parse(body))doesn't give back the same bytes. Capture the raw body before any JSON middleware runs on the webhook route. - Comparing with
==. A normal string comparison exits at the first differing character, which can leak timing information. Usecrypto.timingSafeEqual,hmac.compare_digestin Python, orhmac.Equalin Go. - Wrong secret. Test-mode and live-mode secrets differ, and each endpoint in Stripe has its own. Standard Webhooks secrets must be base64-decoded after removing the
whsec_prefix. - Mixing up encodings. Comparing a hex digest against a base64 header always fails.
- Ignoring the timestamp. A valid signed request captured once can be resent forever unless you check its age.
- A proxy that rewrites the body. Anything that re-encodes, decompresses or reformats the body between the provider and your code breaks the signature. If you forward webhooks, the forwarder must pass the body through byte for byte.
- Returning
200on failure. Return401for a missing or invalid signature so the failure shows up in the provider's logs. See 401 vs 403.
Signatures and webhook forwarding
When webhooks pass through a webhook gateway or forwarder, the provider's signature still covers the original body. Webhook Relay forwards the body and headers unchanged unless you attach a transform, so your endpoint can verify as usual. You can also verify HMAC signatures at the gateway with a function, as shown in the HMAC authentication docs. For webhooks you send to your own customers, the Outbound Gateway signs every delivery with Standard Webhooks headers.
