Skip to content
← All writing

Signing webhooks, and the SSRF hole nobody patches

4 min read
.md
Cover illustration for Signing webhooks, and the SSRF hole nobody patches

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

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

Written by Elson Tan, Head of Technology and co-founder at Nedex Group, working on AI harness and agent infrastructure.

AboutRSS
  • 4 min read

    Sandboxing code an agent wrote for you

    Once an agent writes and runs code, you are executing untrusted input on your infrastructure. The isolation question has known answers. The harder problems are quotas, cancellation and getting results out.

  • 6 min read

    The job was never the code

    Most of my code is now written by an agent, and my output went up rather than down. That is not a story about typing speed. It is about what the job always was underneath the typing.

  • 4 min read

    Metering tokens when the bill is the product

    Billing per message is easy and wrong: one user sends a sentence, another uploads a report. Metering the tokens you actually spend is harder, and the hard parts are idempotency, allowance checks and what to do mid-conversation.

Get in touch

Tell me who you are and what you are working on.

Your details are used only to reply to this message.