# Signing webhooks, and the SSRF hole nobody patches

Most webhook guides stop at HMAC signatures. The bigger risk is on your side: a customer-supplied delivery URL turns your sender into a proxy for your own private network. Here is the full path.

By Elson Tan (https://elsontan.com)
Published: 2025-12-04
Reading time: 4 min
Tags: Security, Architecture
Canonical: https://elsontan.com/blog/signing-and-hardening-webhooks/

> Sign webhook payloads with HMAC and compare in constant time so a receiver can verify you sent them. Then guard the delivery side: a customer-controlled URL is an SSRF primitive, and it needs DNS resolution checked against private ranges before every request, including after each redirect.

---
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](https://datatracker.ietf.org/doc/html/rfc2104)
is the specification; Stripe's [webhook documentation](https://docs.stripe.com/webhooks) is the
clearest worked example of the surrounding conventions.

```ts
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`](https://nodejs.org/api/crypto.html), because `===` on strings
returns early at the first differing byte and leaks the signature one character at a time.

## 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.

The [OWASP SSRF prevention cheat sheet](https://cheatsheetseries.owasp.org/cheatsheets/Server_Side_Request_Forgery_Prevention_Cheat_Sheet.html)
is the reference. The short version for a webhook sender:

1. Accept only `https`. No `http`, no `file`, no `gopher`, no `redis`.
2. Resolve the hostname yourself and check every returned address against the private and reserved
   ranges, rather than pattern-matching the hostname string.
3. Reject on every redirect hop, re-resolving each new host, because a public hostname can 302 to
   `127.0.0.1`.
4. Cap redirects, response size and total time.

```ts
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.

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](https://docs.stripe.com/api/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](https://cheatsheetseries.owasp.org/cheatsheets/Server_Side_Request_Forgery_Prevention_Cheat_Sheet.html), OWASP
- [RFC 2104: HMAC, Keyed-Hashing for Message Authentication](https://datatracker.ietf.org/doc/html/rfc2104), IETF
- [Webhooks](https://docs.stripe.com/webhooks), Stripe documentation
- [Idempotent requests](https://docs.stripe.com/api/idempotent_requests), Stripe API reference
- [Node.js crypto: timingSafeEqual](https://nodejs.org/api/crypto.html), Node.js documentation
