How the attack works
During setup, the provider and recipient exchange a shared secret (secret/signing secret). When sending, the provider computes an HMAC (usually HMAC-SHA256) over the exact, unmodified request body and places the result in an HTTP header.
GitHub uses the header "X-Hub-Signature-256" with the format "sha256=" and HMAC-SHA256 over the raw body. Stripe uses the header "Stripe-Signature" with the format "t=,v1="; the signed data is the concatenation of timestamp and raw body (signed_payload), and the library function constructEvent() checks the signature and timestamp together.
The recipient recomputes the same HMAC with its secret and compares it to the header value. Three points are critical here:
- Compare the raw body: The raw body must be compared, not the re-serialized/parsed JSON, otherwise the HMAC won't match.
- Constant-time comparison: The comparison must be constant-time (e.g. secure_compare/timingSafeEqual) to avoid timing attacks.
- Replay protection via timestamp: The co-signed timestamp prevents replay by having the recipient reject requests whose timestamp deviates too far from the current time (tolerance).
Separately, an SSRF risk arises if the application allows the provider to configure arbitrary callback/webhook target URLs, or itself fetches data from user-controlled URLs. In that case an internal target (internal IP, cloud metadata) can be reached.
Without signature and replay checks, incoming webhooks can be forged.
Example
GitHub verification (official test-vector example from the GitHub docs). Secret: "It's a Secret to Everybody", payload: "Hello, World!". GitHub delivers the request with the header: X-Hub-Signature-256: sha256=757107ea0eb2509fc211221cce984b8a37570b6d7586c22c46f4379c8b043e17. Steps at the recipient:
- Read the raw body unchanged.
- Compute
expected = "sha256=" + HMAC_SHA256(secret, raw_body)as hex. - Compare
expectedagainst the header value in constant time (e.g.Rack::Utils.secure_compareor Nodecrypto.timingSafeEqual). - Process on a match, otherwise reject with an error.
With the values above, the result must be exactly "757107ea0eb2509fc211221cce984b8a37570b6d7586c22c46f4379c8b043e17"; any tampering with the body or a wrong secret produces a different signature and the request is discarded. (Independently recomputed via openssl HMAC and confirmed exact.)
Impact
Without signature verification, anyone who knows the endpoint URL can inject forged events and thereby trigger critical business logic - for example marking an order as paid, crediting a balance, or starting a deployment/CI step.
Without replay protection, a once-intercepted, validly signed event can be resent any number of times (e.g. repeated credits). If the webhook/callback URL is additionally unrestricted, SSRF allows access to internal services and cloud metadata endpoints.
Consequences: financial fraud, data manipulation, unauthorized actions, and access to internal systems.
How to protect against it
- Verify every incoming webhook request with the shared secret via HMAC (SHA-256) before processing the payload - GitHub: X-Hub-Signature-256, Stripe: Stripe-Signature.
- Sign and verify the unmodified raw body, not the parsed/re-serialized JSON.
- Perform the signature comparison in constant time (e.g. Rack::Utils.secure_compare, Node crypto.timingSafeEqual), never with plain == .
- Enable replay protection: check the co-signed timestamp and reject requests outside a small tolerance window; where possible, process a nonce/event ID only once (idempotency).
- Use the provider's libraries (e.g. Stripe constructEvent()), which validate signature and timestamp together, instead of building your own logic.
- Generate the secret randomly with high entropy, store it securely (not in code), and keep it rotatable; expose the endpoint only over HTTPS.
- If the signature check/header is missing, reject the request instead of processing it.
- Avoid SSRF: don't allow webhook/callback target URLs freely, but check them against an allowlist of permitted domains/IP addresses (v4 and v6) using strict string comparison; block internal IP ranges and cloud metadata endpoints (e.g. 169.254.169.254), and account for DNS rebinding.
Sources
- Validating webhook deliveries GitHub Docs, 2026
- Receive Stripe events in your webhook endpoint Stripe Docs, 2026
- Verify webhook signatures manually Stripe Docs, 2026
- Server-Side Request Forgery Prevention Cheat Sheet OWASP Cheat Sheet Series, 2026