TL;DR
Webhook SSRF is what happens when the URL a customer registers for callbacks is fetched by a server standing inside your network. The check that stops it runs on the address the URL resolves to, not on the text of the URL, and it has to survive a redirect that arrives after the check has already passed. OWASP dropped SSRF as its own Top 10 heading in 2025 and folded CWE-918 into Broken Access Control, which names the real problem: the request carries your server's position, and position is what grants access.
A webhook sender fetches a URL that somebody else chose. Server-side request forgery has no more to it than that, and a payments platform ships it on purpose, because a callback is how a merchant's own system finds out an invoice was paid.
What turns the feature into an attack is the starting point of the request. Delivery runs from inside your perimeter, on a host with a routing table and an outbound identity no browser on the public internet has. Someone registers an address; your platform connects to it for them, on a schedule, with retries.
The check that holds runs on the resolved address, before the socket opens, and the redirect that would move the destination afterwards is refused rather than followed. Everything below is about where that check can stand and what holding it costs the merchant at the other end.
What is webhook SSRF?
It is the bug class where an attacker picks the destination of a request your server makes, arriving through the one input field built to accept exactly that.
The familiar SSRF findings are the ones nobody meant to build. A PDF renderer follows an image URL; an avatar importer fetches one on the user's behalf. Nobody set out to hand strangers an HTTP client, and the finding reads afterwards as an oversight. A webhook sender is the deliberate case: the field is documented, and the worker that fetches it has no other job.
OWASP moved the category in 2025. SSRF had its own slot in the 2021 list as A10; in the 2025 Top 10 it is gone as a heading, and CWE-918 sits among the notable weaknesses under A01, Broken Access Control.
The new placement describes the damage better than the old name did. The request is authentic. Your infrastructure sent it, and what it reaches is reachable because of who sent it.
Why is the network position worth more than the response?
Because the response usually never reaches the attacker, and the attack works anyway.
A delivery worker reads a status code and drops the body. Nothing is echoed back into a dashboard, so whoever registered the URL learns one bit per attempt: the connection was accepted, or it was not. That is the blind variant, and it is why teams reasoning about SSRF as data theft underrate it. One bit, repeated, maps a network.
Position is the asset. Inside the perimeter, the worker can route to hosts that were never built to answer anyone outside it — plenty of them from an era when being on the internal network was the authentication. Outside the perimeter, the platform's egress address is often sitting on an allowlist at some partner who agreed to take traffic from you and from nobody else.
The retry ladder is what puts a payments platform ahead of a PDF renderer here. A sender that retries on a backoff schedule turns one registration into a job that fires again and again, on infrastructure someone else keeps running.
Where does the check belong?
After the name resolves and before the connection opens, which is an awkward place to stand in most HTTP client libraries.
The filter on the raw string gets written first, because it is the one you can write without leaving the request handler. It fails for a structural reason rather than a clever one: a hostname is a pointer, and what it points at is decided by a DNS record the person registering the URL may own. Reading the text tells you nothing about where the packet goes. OWASP's prevention guidance says the application has to retrieve the addresses behind the name, A and AAAA both, and apply the address rules to what comes back.
The awkwardness is why the naive version keeps shipping. A client library wants to take a URL and hand back a response, while the hook you need sits between two steps it treats as its own business. Reaching it means resolving the name yourself or hooking the socket, and either is more work than a regular expression on a form field.
Which address ranges have to be refused?
More than the list most engineers write from memory.
Loopback and the RFC 1918 private blocks are the obvious entries. Link-local belongs there too — it is the range cloud instance metadata answers on, and the consequence of reaching it is credentials.
The two that get left out are younger than the mental model people work from.
Shared address space, 100.64.0.0/10, was reserved by RFC 6598 in April 2012
for carrier-grade NAT, and the RFC records it as distinct from RFC 1918 because
it belongs to service-provider networks.
A filter pattern-matching 10. and 192.168. has no opinion about it, and it
misses half of RFC 1918 on the way past.
IPv6 unique local addresses, fc00::/7, come from RFC 4193, which defines them
as not routable on the global internet and routable inside a site.
No webhook should ever be sent to a destination answering that description, and a range list written only for IPv4 does not mention them.
Paymos webhook delivery refuses more than the classes named above. Loopback and
IPv6 link-local are tested on their own, and the range check covers 0.0.0.0/8,
10.0.0.0/8, 127.0.0.0/8, 169.254.0.0/16, 172.16.0.0/12 and
192.168.0.0/16 alongside shared address space and IPv6 ULA. After those come
the three blocks RFC 5737 set aside for documentation: 192.0.2.0/24,
198.51.100.0/24 and 203.0.113.0/24.
Addresses are normalised before any of that runs, so an IPv4 address written in IPv6 form meets the same list as its plain twin.
Address validation here is a setting, not a property of the build. It ships on, and nothing in production turns it off, which is a weaker sentence than cannot be disabled.
Why does a redirect defeat a check that passed?
Because the URL you validated stops being the URL you fetch.
The worker resolves a hostname, gets a public address, approves it, opens the
connection. The server answers 302 with a new location. If the HTTP client
follows redirects, and .NET's HttpClient and Python's requests both do
unless told otherwise, the second request was never checked at all: the check
was an event in the life of the first one.
The prevention cheat sheet closes this in a clause: disable redirect support in the web client.
It is blunt and it is right. An endpoint that receives callbacks has no reason to hand them on to a second address, and a merchant who has moved theirs can register the new one. Paymos delivery follows no redirect. That refusal is wired into the handler rather than governed by the setting above, so it holds even where address validation is off.
A quieter version of the same problem sits between the check and the connection even when nothing redirects. A DNS answer has an expiry, and the approval you granted covered the answer you saw. That gap is where DNS rebinding lives. Closing it is a property of how the client is wired rather than a rule you can add to a validator, which is why it tends to be the last piece a sender grows. On Paymos delivery the name resolves once, the socket opens onto the first address that passed, and nothing looks it up again in between — leaving rebinding no second answer to work with.
What does fail-closed look like in a delivery worker?
A refusal that costs the sender nothing, and a budget those refusals cannot drain.
Ordering carries the first half. A destination failing validation is rejected before any connection is attempted, so a blocked registration costs the sender no outbound capacity at all.
The second half is resource limits, which get skipped because they look like reliability work. On Paymos webhook delivery they were built for reliability, and they bound this too:
- Ten seconds per attempt. Without a deadline, a destination that answers the handshake and then goes quiet ties up a worker until something else gives out, and enough of those stop the queue draining.
- Eleven attempts close an event: the first try plus ten retries, spaced from a minute out to eight hours, spanning roughly sixteen hours. Retrying without a ceiling would hand a chosen destination a traffic source that reloads itself.
- Ten endpoints per merchant environment. The registration limit is what stands between one account and an unbounded share of outbound capacity.
Those attempts bound the event and never the endpoint. A cycle that runs out marks the event failed and leaves the registration live, so a receiver that was down for a day comes back to replayable events.
What does the defence cost the merchant?
Real endpoints break on it, and the breakage looks like an outage rather than a control.
A callback cannot point at localhost or at a machine on an office LAN, so
local development needs a tunnel that puts a public hostname in front of the
handler. Every integration meets that one on the first afternoon.
Refusing redirects means the registered URL has to be the final one. An apex
domain answering 301 and sending everything on to the www host, or a path
that moved last year and still bounces, fails delivery while returning a
perfectly healthy page to a browser at the same address. Link wrappers and URL
shorteners in front of a callback are the same problem wearing a different hat.
The subtle one is a DNS record aimed at the wrong address. When the A record for an endpoint carries the address a router shows on the inside rather than the one the world can reach, the destination lands in a blocked range and nothing leaves the building.
One failure is loud. A callback URL has to be HTTPS, and an http:// one is
refused when the endpoint is created and again when it is edited. The refusal
lands in front of whoever registered it.
None of the others announce themselves. The merchant sees no callbacks and opens a ticket saying webhooks are not arriving; the handler gets debugged first, and the answer is in the registered URL.
What should you ask a payment processor about this?
Ask where the check runs, because every other answer follows from that one.
A vendor validating the resolved address will say so without prompting and will name the ranges. Loopback and RFC 1918 are the easy half; an answer that reaches shared address space and IPv6 unique local addresses came from somebody reading current RFCs rather than recycling an old checklist. Ask whether automatic redirects are followed, and what one refused destination does to delivery capacity for every other merchant on the platform.
Then read the vendor's security page and check that it stops where the engineering stops. A page describing a defence in wider terms than the component enforcing it is a different risk from the one this article is about, and it is the one you can audit from outside.
If you are on the other side of this and building the sender, the hard part of the whole control is that it stays silent when it works and looks exactly like a bug when it fires. Build the refusal so it names the rule it hit, and say it to the merchant in the API response rather than only to your own log.
| Where the check runs | What it sees | What still gets past it | |
|---|---|---|---|
| On the URL string | The text a customer typed into the form | A hostname whose DNS record points inside | |
| On the hostname | Whether the name is one you already know | Any name you do not know, wherever it resolves | |
| On the resolved addresses | Every A and AAAA record behind the name | A redirect returned in the response | |
| On each connection, every attempt | The address this request is about to reach | Whatever the range list left out |
Frequently asked questions
What is webhook SSRF?
Server-side request forgery through the callback URL field. The customer chooses the destination, and a background worker inside your network connects to it, which gives the request a network position the customer does not have.
Can I point a webhook URL at localhost for testing?
Not on a sender that refuses private destinations. Paymos blocks loopback, private, link-local, CGNAT and IPv6 ULA addresses, so a local handler needs a tunnel to give it a public hostname.
Does Paymos follow redirects on webhook delivery?
No. Automatic HTTP redirects are not followed, so the URL you register has to be the final one rather than one that bounces to it.
Is blocking 127.0.0.1 enough to stop SSRF?
No. A hostname resolves wherever its DNS record says it does, so the check has to run on the resolved addresses, and the refusal list has to reach private, link-local, carrier-grade NAT and IPv6 unique local ranges.
Why did my webhooks stop arriving after I changed the URL?
A destination in a blocked range never gets the request. One that redirects gets it, and the 3xx counts as a failed attempt. Check that the hostname resolves to a public address and that it is the final URL.
Sources
- 1. OWASP Top 10:2025 — A01 Broken Access Control (accessed 2026-09-15)
- 2. OWASP — Server Side Request Forgery Prevention Cheat Sheet (accessed 2026-09-15)
- 3. RFC 6598 — IANA-Reserved IPv4 Prefix for Shared Address Space (accessed 2026-09-15)
- 4. RFC 4193 — Unique Local IPv6 Unicast Addresses (accessed 2026-09-15)
- 5. Microsoft Learn — HttpClientHandler.AllowAutoRedirect (accessed 2026-09-15)
- 6. Paymos — Webhook documentation (accessed 2026-09-15)
Last reviewed Sep 15, 2026


