Customers type in the URLs your webhooks go to, and sooner or later someone will try http://169.254.169.254/. We cover checking URLs at registration and again at send time, and pinning the resolved IP, with Node.js code.
Customers type in the URLs your webhooks go to. If you send to them without checks, someone can register http://169.254.169.254/ or the address of an internal admin panel, and your servers will make requests into your own network on their behalf. That's server-side request forgery (SSRF). The defense comes down to three rules: check the URL when it's registered and again right before each send, connect to the exact IP address you checked, and never follow redirects.
What can go wrong
Your webhook workers sit inside your network. Without checks, a customer-supplied URL can reach:
-
Cloud metadata services:
169.254.169.254on AWS and Azure,metadata.google.internalon GCP. These can hand out credentials. -
Internal services: anything in
10.0.0.0/8or192.168.0.0/16, admin ports onlocalhost. - Your own APIs that skip authentication for requests from inside the network.
If you show response bodies in the customer's delivery log, the internal service's response can end up on the attacker's screen.
Checks at registration
When a customer adds an endpoint:
-
Parse it as a URL. Use a standard parser and check the hostname it produces. The WHATWG URL parser normalizes
http://2130706433/andhttp://0x7f.1/to127.0.0.1, so checking the raw string misses them. -
Allow
httpsonly. Plain http lets anyone on the path read and modify payloads. - Restrict ports, for example to 443 and 8443, so the URL can't target other services' ports.
-
Reject credentials in the URL (
https://user:pass@example.com/). -
Reject IP-literal hosts such as
https://10.0.0.5/orhttps://[::1]/. -
Reject internal names:
localhostand*.localhost,.local,.internal,.home.arpa, and single-label names. RFC 6761 says name resolution should always return a loopback address for localhost names (RFC 6761). - Resolve the name. Look up both A and AAAA records; reject if any answer is internal, or if nothing resolves.
Failing at registration gives the customer an immediate, specific error. But step 7 only tells you about DNS at that moment.
Checks at send time
DNS rebinding defeats registration checks: the attacker registers their own domain, has it resolve to a public IP during registration, then switches it to 127.0.0.1. With a short TTL they can even return different answers to your check and to the connection that follows it.
OWASP's SSRF cheat sheet says to resolve all IP addresses behind the domain (A and AAAA), apply the same checks to each, and disable redirect support in the HTTP client (OWASP SSRF Prevention Cheat Sheet, checked on 2026-09-27). Before every send:
- Resolve the name and check every returned IP. If any is internal, don't send.
- Connect to the IP you checked. Don't let the HTTP client resolve the name again.
- Use the original hostname for TLS SNI and certificate validation.
- Don't follow redirects; record a 3xx as a failure.
- Cap the time you wait and how much of the response you read.
Address ranges to block
| Range | What it is |
|---|---|
0.0.0.0/8, 127.0.0.0/8
|
"This host" and loopback |
10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16
|
Private networks (RFC 1918) |
100.64.0.0/10 |
Carrier-grade NAT |
169.254.0.0/16 |
Link-local, including cloud metadata |
192.0.0.0/24, 192.0.2.0/24, 198.18.0.0/15, 198.51.100.0/24, 203.0.113.0/24
|
Reserved, documentation and benchmarking |
224.0.0.0/4, 240.0.0.0/4
|
Multicast and reserved |
::1, ::, fc00::/7, fe80::/10
|
IPv6 loopback, unspecified, unique local, link-local |
::ffff:0:0/96, 64:ff9b::/96, 2002::/16, 2001::/32
|
IPv6 forms that carry an IPv4 address (mapped, NAT64, 6to4, Teredo) |
The last row is the one people miss. ::ffff:127.0.0.1 is written as IPv6 but connects to IPv4 loopback.
A Node.js implementation
Resolve, check, then pin the connection to the checked IP. Node's net.BlockList evaluates IPv4-mapped IPv6 addresses (::ffff:…) against its IPv4 rules. It also maps IPv4 addresses to that form when comparing them with IPv6 rules, so a broad IPv6 entry such as ::/8 or ::ffff:0:0/96 would block every IPv4 address - keep the IPv6 list specific.
import { lookup } from 'node:dns/promises';
import https from 'node:https';
import { BlockList, type LookupFunction } from 'node:net';
const blocked = new BlockList();
for (const [net, bits] of [
['0.0.0.0', 8], ['10.0.0.0', 8], ['100.64.0.0', 10], ['127.0.0.0', 8], ['169.254.0.0', 16],
['172.16.0.0', 12], ['192.0.0.0', 24], ['192.0.2.0', 24], ['192.168.0.0', 16], ['198.18.0.0', 15],
['198.51.100.0', 24], ['203.0.113.0', 24], ['224.0.0.0', 4], ['240.0.0.0', 4],
] as const) blocked.addSubnet(net, bits, 'ipv4');
for (const [net, bits] of [
['::', 128], ['::1', 128], ['64:ff9b::', 96], ['2001::', 32], ['2002::', 16],
['fc00::', 7], ['fe80::', 10], ['ff00::', 8],
] as const) blocked.addSubnet(net, bits, 'ipv6');
// IPv4-mapped addresses such as ::ffff:127.0.0.1 are checked against the IPv4 rules above
export async function safePost(rawUrl: string, headers: Record<string, string>, body: string) {
const url = new URL(rawUrl); // already passed the registration checks
const addrs = await lookup(url.hostname, { all: true }); // A and AAAA
if (addrs.length === 0) throw new Error('no address');
if (addrs.some((a) => blocked.check(a.address, a.family === 6 ? 'ipv6' : 'ipv4'))) {
throw new Error('blocked address'); // don't tell the customer which internal IP it was
}
const pinned = addrs[0]!;
// Never resolve again: connect to the address we just checked
const pinnedLookup: LookupFunction = (_host, opts, cb) =>
(opts as { all?: boolean }).all
? (cb as any)(null, [pinned])
: cb(null, pinned.address, pinned.family);
return new Promise<number>((resolve, reject) => {
const req = https.request(
url,
{ method: 'POST', headers, lookup: pinnedLookup, servername: url.hostname, timeout: 15_000 },
(res) => {
res.resume(); // no redirect following; a 3xx is returned as a failure
resolve(res.statusCode ?? 0);
},
);
req.on('timeout', () => req.destroy(new Error('timeout')));
req.on('error', reject);
req.end(body);
});
}
https.request still validates the certificate against the original hostname, so pinning the IP doesn't weaken TLS. The timeout option is an idle timeout; add a separate timer if you need an overall deadline. fetch doesn't make pinning easy, so use https.request or an HTTP client that lets you choose the connect address.
Network-level defenses
Application checks can have gaps. Network controls limit the damage when they do.
- Send from an isolated egress. Run webhook delivery somewhere that can't reach your internal network. The Standard Webhooks spec also recommends proxying webhook requests and isolating workers in private subnets (Standard Webhooks spec).
- Use an egress proxy. Stripe's open-source Smokescreen is an HTTP CONNECT proxy that resolves each requested hostname and checks that it's a publicly routable address before allowing the connection. Stripe uses it for outbound traffic such as webhooks.
- Require tokens for instance metadata. AWS IMDSv2 requires a session token that SSRF requests typically can't obtain; OWASP lists it as defense in depth.
For the rest of the sender and receiver security checks beyond SSRF, see the webhook security checklist.
What to show customers when a check fails
At registration, say why ("only https URLs are accepted", "internal hostnames can't be registered"). At send time, don't reveal which internal address the name resolved to, or you've built a network scanner. Log something like "destination address not allowed."
What Webhook Admin checks
At registration, Webhook Admin enforces (as of 2026-09-27):
- https only, on port 443 or 8443
- No credentials in the URL
- No IP-literal hosts
- No
localhost,.local,.internal,.localdomain,.home.arpa,.lanor.intranetnames, and no names without a dot - A and AAAA lookups; names that resolve to internal addresses, or don't resolve, are rejected with a 422
It checks name resolution again right before sending, never follows redirects, and times out after 15 seconds. See create an endpoint for the API, and how to send webhooks for the rest of what a sender needs.
If you'd rather not build SSRF-safe delivery to customer URLs yourself, you can try Webhook Admin for free (50,000 messages a month): https://app.webhookadmin.com/signup
Quick FAQ
Isn't validating the URL at registration enough?
No. A hostname that resolved to a public address when it was registered can be changed to resolve to 127.0.0.1 or 169.254.169.254 later. Resolve the name right before sending, check the result, and connect to that exact IP.
How do I test against localhost during development?
Put "allow private addresses" behind a setting that is off by default and only enabled in development, so it can never be on in production. To test with production-equivalent checks, give your local server a public https URL with an ngrok or cloudflared tunnel.
What about IPv6?
Resolve both A and AAAA records and check every answer. Besides ::1, fc00::/7 and fe80::/10, watch for IPv6 forms that embed an IPv4 address (IPv4-mapped, NAT64, 6to4, Teredo) - check the embedded IPv4 or block those ranges outright.
Thanks for reading! Do you check webhook URLs only at registration, or before every send? We'd love to hear in the comments.
This post first appeared on the Webhook Admin blog.
Top comments (0)