My document processing platform posts a notification when a scan finishes. Customers paste in a URL and I send the result there.
Read that again. A stranger types an address into a form, and my servers go and fetch it. From inside my network.
I spent a week reading about webhook security before I noticed that. Every article was about HMAC signatures. They are all correct, and they are all about protecting the person receiving the webhook. Almost nobody writes about the person sending it.
That URL field is a way to make my infrastructure fetch anything a customer names. Including the things only my infrastructure can reach.
Signing, briefly
The receiver needs to know the payload came from you and has not been altered. Compute an HMAC over the raw body with a per-endpoint secret and send it as a header. RFC 2104 is the specification; Stripe’s webhook documentation is the clearest worked example of the surrounding conventions.
import { createHmac, timingSafeEqual } from 'node:crypto'
export function sign(rawBody: string, secret: string, timestamp: number) {
const payload = `${timestamp}.${rawBody}`
const mac = createHmac('sha256', secret).update(payload).digest('hex')
return `t=${timestamp},v1=${mac}`
}
export function verify(rawBody: string, header: string, secret: string, toleranceSec = 300) {
const parts = Object.fromEntries(header.split(',').map((p) => p.split('=')))
const age = Math.abs(Date.now() / 1000 - Number(parts.t))
if (!Number.isFinite(age) || age > toleranceSec) return false
const expected = createHmac('sha256', secret).update(`${parts.t}.${rawBody}`).digest()
const given = Buffer.from(parts.v1 ?? '', 'hex')
return expected.length === given.length && timingSafeEqual(expected, given)
}
Three things there are easy to get wrong. Sign the raw body, before any JSON parse and re-stringify,
because key order and whitespace change the bytes. Include the timestamp inside the signed payload
and reject anything older than a few minutes, or a captured request can be replayed forever. And
compare with timingSafeEqual, because === on strings
returns early at the first differing byte and leaks the signature one character at a time.
| Mistake | What it costs you | The fix |
|---|---|---|
| Signing the parsed and re-stringified body | Signature fails at random, because key order and whitespace change the bytes | Sign the raw body, before any parse |
| Timestamp outside the signed payload, or absent | A captured request replays forever | Sign timestamp.body and reject anything older than 300 seconds |
Comparing signatures with === | Returns early at the first differing byte, leaking the signature one character at a time | timingSafeEqual on equal-length buffers |
Why is a customer’s URL a security problem?
Because your server is standing somewhere a stranger’s laptop cannot reach, and you just offered to fetch things on their behalf.
Try it from their side. Put http://169.254.169.254/latest/meta-data/ in the webhook field. On most
cloud providers that address hands out the instance’s credentials to anything asking from inside the
instance. Your delivery worker is inside the instance.
Same trick reaches your Redis, your Kubernetes API, an admin panel listening on 127.0.0.1, and
anything else on the private network. The attacker does not even need to see the response directly.
Your own delivery logs will show them what came back.
What a customer-supplied URL actually reaches:
| Target | Address a customer would enter | What it hands over |
|---|---|---|
| Cloud instance metadata | http://169.254.169.254/latest/meta-data/ | Instance credentials, on most providers |
| Loopback services | http://127.0.0.1:6379/ | Redis, admin panels, anything bound to localhost |
| Private network | http://10.0.0.0/8, 192.168.0.0/16 | Internal APIs never meant to face the internet |
| Carrier-grade NAT range | 100.64.0.0/10 | Frequently forgotten by hand-written blocklists |
| A public hostname with a private A record | evil.example.com | Everything above, past any string-matching filter |
The OWASP SSRF prevention cheat sheet is the reference. The short version for a webhook sender:
- Accept only
https. Nohttp, nofile, nogopher, noredis. - Resolve the hostname yourself and check every returned address against the private and reserved ranges, rather than pattern-matching the hostname string.
- Reject on every redirect hop, re-resolving each new host, because a public hostname can 302 to
127.0.0.1. - Cap redirects, response size and total time.
const BLOCKED_V4 = [
/^0\./, /^10\./, /^127\./, /^169\.254\./, /^172\.(1[6-9]|2\d|3[01])\./,
/^192\.168\./, /^100\.(6[4-9]|[7-9]\d|1[01]\d|12[0-7])\./,
]
export async function assertPublicHost(hostname: string) {
const { address } = await lookup(hostname) // resolve, do not trust the string
if (address.includes(':')) throw new BlockedTarget('ipv6 not permitted')
if (BLOCKED_V4.some((r) => r.test(address))) throw new BlockedTarget(address)
}
Resolving rather than string-matching is the part that gets skipped. Blocking the literal
127.0.0.1 in the URL does nothing against a hostname whose A record points there, and registering
such a hostname takes about a minute.
There is a race here worth naming: you resolve, you check, then the HTTP client resolves again when it connects, and a DNS record with a one-second TTL can change between the two. Closing it properly means connecting to the address you validated and passing the hostname for TLS separately. Most teams accept the race. I mention it because pretending the check is airtight is worse than knowing where it leaks.
Retries, and the receiver’s side of the bargain
Delivery fails. Networks drop, receivers deploy, endpoints return 500 for ten minutes.
I retry three times with exponential backoff and then mark the delivery dead, keeping the attempt history so the customer can see what I sent and what came back. Three is low compared to the five or six many platforms use. It suits my failure profile: endpoints that are down for ten minutes recover inside three attempts, and endpoints that are down for a day are not coming back before someone notices.
Signing and SSRF protection are often discussed together and belong to different parties:
| Receiver’s job | Sender’s job | |
|---|---|---|
| Threat | A forged or replayed payload | Being used as a proxy into your own network |
| Primary control | Verify HMAC in constant time | Resolve and validate the target address |
| Timestamp handling | Reject anything older than the tolerance | Include the timestamp inside the signed payload |
| Duplicates | Treat the event ID as an idempotency key | Guarantee the ID is stable across retries |
| Redirects | Not applicable | Re-resolve and re-check on every hop |
| What most guides cover | Thoroughly | Almost not at all |
Retries make duplicate delivery a certainty, not a possibility. A response can be lost after the receiver has committed. So every event carries a stable ID and the documentation tells integrators to treat it as an idempotency key, which is the same contract Stripe’s idempotent requests define in the opposite direction.
One operational detail that saves a support ticket every time: during a secret rotation, sign with both the old and the new secret for a period and send both signatures. Otherwise in-flight retries from before the rotation fail verification and look like an outage.
Sources
- Server Side Request Forgery Prevention Cheat Sheet, OWASP
- RFC 2104: HMAC, Keyed-Hashing for Message Authentication, IETF
- Webhooks, Stripe documentation
- Idempotent requests, Stripe API reference
- Node.js crypto: timingSafeEqual, Node.js documentation
Elson Tan