Reading the SSRF code sample, the bug looks like sloppy input validation. Your server takes a URL from a user and fetches it. Fix the validation, fix the bug.
That framing misses why it matters. The reason this particular missing check costs you your cloud credentials is not the URL. It is where your server is standing when it makes the request.

Reading the diagram
Start at the top. The attacker is red, out on the internet, and the label states the constraint they are working around: they cannot reach anything in the bottom row. Try curl http://169.254.169.254/ from your laptop right now and it will hang and time out. That address is not routable from where you are.
Your API endpoint is also red, because anyone can call it. It does something that sounds harmless and is extremely common in AI-generated code: it accepts a URL and fetches it, for a link preview, an image import, a webhook test, an avatar upload from a URL.
The green box is the pivot. Your server is inside the private network. Everything below it is reachable from there and nowhere else.
Now follow the three arrows down, and notice that none of them involve the attacker at all. Your server is doing all of this on its own, correctly, exactly as designed:
- The cloud metadata service at
169.254.169.254answers any request from an instance inside the network, with no authentication, because authentication would be circular. It is how a machine finds out what role it has. On AWS it returns IAM credentials. - Your admin dashboard on localhost never asked for a login because it only listens on the loopback interface. Binding to localhost was the security control.
- Your internal service trusts anything already on the network, which is a decision that was reasonable when only your own code was on the network.
Every one of those three is a sensible design given a correct assumption: that only trusted code can send them a request. The attacker did not break any of them. They borrowed your server's address.
The metadata endpoint is checked first, every time. It requires no credentials, returns credentials, and lives at the same address on every instance in AWS, GCP and Azure. An automated scanner probing your URL-fetching endpoint tries 169.254.169.254 before anything else, because the payoff is the largest available.
What this changes about the fix
Once you see it as a position problem, the ranking of the defences changes.
Validating the URL string is the weakest layer, and it's the one most people stop at. A blocklist of private ranges gets bypassed by a hostname that resolves to 127.0.0.1, by a public URL that 302-redirects to an internal one after your check has already passed, and by alternate encodings of the same address.
Better, in order:
- Allowlist the hostnames you meant to talk to. It fails closed. If your feature only ever fetches from
images.unsplash.com, say so. - Resolve the hostname yourself and check the resolved IP, then re-check after every redirect, not just the first request.
- Move the fetching somewhere with nothing to steal. A small worker with no route to your VPC, no instance role, and no localhost services turns a successful SSRF into a wasted request. This is the layer that survives your validation being wrong.
- Turn on IMDSv2 (or your cloud's equivalent) so the metadata service requires a
PUTto get a token first. A plainGETfrom a naive fetch then returns nothing.
The last two are the ones the diagram argues for. They change your server's position rather than trying to guess every string an attacker might send.
The Mermaid source
Copy this and swap in your own internal services.
flowchart TB
A["The attacker, out on the internet<br/>cannot reach anything below your server"]
E["Your API endpoint<br/>it takes a url and fetches it for you"]
S["Your server, inside the private network"]
L["Your admin dashboard<br/><code>localhost:8080</code>"]
M["Cloud metadata service<br/><code>169.254.169.254</code>"]
I["An internal service<br/><code>internal-api.local</code>"]
A -->|"url=<code>http://169.254.169.254/</code>"| E
E -->|"your server fetches it,<br/>not the attacker"| S
S -->|"it never asks for a login"| L
S ==>|"<b>hands back your keys</b>"| M
S -->|"trusts anything<br/>already on the network"| I
classDef danger fill:#fef2f2,stroke:#ef4444,stroke-width:3px,color:#1c1917
classDef safe fill:#ecfdf5,stroke:#10b981,stroke-width:3px,color:#1c1917
classDef store fill:#f5f5f4,stroke:#57534e,stroke-width:2.5px,color:#1c1917
class A,E danger
class S safe
class L,M,I store
linkStyle 3 stroke:#ef4444,stroke-width:4px
Why can my server reach 169.254.169.254 when I cannot?
Because that address is link-local. It only resolves from inside the cloud network your instance is running in, and every instance in AWS, GCP and Azure can reach it by design. That is how a machine asks the platform who it is. From your laptop the address goes nowhere. From your server it returns credentials.
Is SSRF still a problem if my API only fetches images?
Yes. Your server does not know it is fetching an image until after the request completes, and by then it has already reached whatever address you handed it. The response body being unusable does not undo the request. Blind SSRF, where the attacker never sees the response, is still enough to hit internal endpoints that act on a GET.
Does blocking private IP ranges fix it?
It is necessary and not sufficient on its own. Attackers get around naive blocklists with DNS names that resolve to private addresses, redirects from a public URL to an internal one, and IPv6 or decimal encodings of the same address. Resolve the hostname yourself, check the resolved IP, and re-check after every redirect.
Which URLs should I actually allow?
As few as possible. An allowlist of specific hostnames you intend to talk to beats any blocklist, because it fails closed. If you genuinely need to fetch arbitrary user URLs, do it from a separate service with no network route to anything private, so a successful SSRF reaches nothing worth having.
Does your app fetch URLs for users?
A scan probes your endpoints from outside and reports the ones that will fetch whatever you hand them.