eusend/Blog
DocsPricingMigrateGuidesBlogSign in
Get started

← Blog·Jul 12, 2026engineeringsecurity

Pinning webhook deliveries against DNS rebinding

How a webhook URL that resolves to a public IP at validation time can point somewhere private at connect time — and how we closed that gap.

SivertSivertFounderBuilding eusend — self-hosted email infrastructure that never leaves the EU.

If your product delivers webhooks, you're running a service that makes HTTP requests to URLs your users choose. That's server-side request forgery as a feature — so the interesting question is how you keep it pointed at the internet and away from your own network.

The obvious defense, and why it's not enough

The first instinct is to validate the URL when the webhook is created: resolve the hostname, check the IPs aren't private, save it. The problem is time of check vs. time of use. The customer controls the DNS for their webhook hostname, and nothing stops them from answering with a public IP during validation and 10.0.0.5 when the delivery worker connects five minutes later. That's DNS rebinding, and a plain fetch() walks right into it, because it re-resolves the hostname on every request.

Resolve once, connect to what you vetted

Our delivery worker resolves the hostname exactly once per delivery, validates every address the lookup returns, and then makes the TCP connection to the vetted IP literal — no second DNS resolution ever happens:

const req = https.request({
  hostname: addr.address,     // the pinned, validated IP
  servername: sni,            // TLS still validates against the real hostname
  headers: { Host: url.host } // and the origin sees the Host it expects
})

The servername/Host split is the part people miss: you want the socket pinned to the IP you checked, but TLS certificate validation and virtual hosting still need the original hostname. Setting them separately gives you both.

The private-address check itself also has to be paranoid — 127.0.0.1 is easy, but so must be decimal literals (2130706433), IPv4-mapped IPv6 (::ffff:7f00:1), link-local ranges that cloud metadata services live on (169.254.0.0/16), CGNAT space, and IPv6 ULA. And redirects are never followed: a 3xx from the customer's server is recorded as a failed delivery, not chased to a new host.

Signatures, while we're at it

Every delivery is signed with a per-endpoint secret (HMAC-SHA256 over id.timestamp.body, Svix-compatible headers), so receivers can verify both origin and freshness. If you're consuming webhooks — ours or anyone's — verify the signature and reject stale timestamps.

None of this is exotic; it's just the difference between "we validate webhook URLs" and validating the thing you actually connect to.