You add a feature that lets users enter a URL to preview a link, fetch an RSS feed, or import data from an external source. It works fine in testing. Then six months later you find out an attacker used it to pull AWS IAM credentials from 169.254.169.254 and spent a weekend exfiltrating your S3 buckets. That's SSRF. Block any URL your server fetches that isn't explicitly allowlisted — anything else is a foothold into your internal network.
What SSRF Actually Gives an Attacker
The core problem is trust. Your server sits inside a network perimeter. Internal services — databases, admin panels, Kubernetes API servers, cloud metadata endpoints — trust requests from that server in ways they'd never trust the public internet. SSRF lets an attacker borrow that trust.
Practical targets once SSRF is established:
- AWS/GCP/Azure metadata —
http://169.254.169.254on AWS and GCP returns IAM credentials, instance identity, and user data scripts. This is how the Capital One breach worked. - Internal HTTP services — Elasticsearch on port 9200, Redis on 6379 (via Gopher SSRF), Jenkins, Consul, etcd — anything that assumes requests are trusted because they come from inside.
- Port scanning — response time differences or error messages reveal which internal ports are open.
- Cloud function invocation — triggering internal Lambda functions or Cloud Run endpoints that aren't exposed externally.
- Localhost admin interfaces — Grafana, RabbitMQ management UI, pgAdmin running on
127.0.0.1:PORT.
How to Find SSRF Vulnerabilities
Any parameter that accepts a URL, hostname, or IP is a potential SSRF sink. Hunt for:
url=,src=,redirect=,uri=,path=,endpoint=,feed=,webhook=query params- JSON/XML bodies with URLs for webhook callbacks or resource fetching
- File import features (import from URL, fetch avatar from Gravatar, PDF generation from URL)
- Any feature described as "link preview" or "fetch external content"
Testing approach: Replace the target URL with your own server (use Burp Collaborator, interactsh, or a quick ngrok tunnel pointing at nc -l). If your server receives a request, you have SSRF. For internal targets, try:
http://127.0.0.1/admin
http://169.254.169.254/latest/meta-data/
http://[::1]/admin ← IPv6 localhost bypass
http://0.0.0.0/ ← resolves to localhost on many systems
Blind SSRF is subtler. You won't see the response, but DNS callbacks will fire when your server resolves a hostname — start there before assuming a feature isn't vulnerable.
Common Bypass Techniques
Developers often add SSRF mitigations that are easy to circumvent. Knowing these is essential for both testing and building solid defenses.
String-based blocklists that check for '169.254.169.254' as text won't match these representations — but they all resolve to the same IP.
IP encoding tricks. Blocklists that check for 169.254.169.254 as a string can be bypassed with:
- Decimal notation:
http://2852039166/(same IP) - Octal:
http://0251.0376.0251.0376/ - Hex:
http://0xa9fea9fe/ - IPv6-mapped:
http://[::ffff:169.254.169.254]/
DNS rebinding. You control evil.com. It first resolves to a legitimate IP (passes your DNS check), then your server makes the real request — by then the DNS TTL has expired and it resolves to 169.254.169.254. This defeats check-then-use validation patterns.
Redirects. Your validation checks the initial URL and it looks fine. But the server follows a 302 Location: http://169.254.169.254/... redirect. Unless you re-validate after every redirect hop, this bypasses your check.
Protocol smuggling. Some libraries support gopher:// or file:// — a gopher:// URL can craft raw TCP payloads, which is how SSRF was used to send Redis commands in older setups.
The gotcha junior devs almost always miss: validating the URL string is not enough. You must resolve the hostname to an IP and validate the resolved IP, then make the request — and if the library follows redirects automatically, you need to validate each hop's resolved IP too.
How to Prevent SSRF Properly
Use an allowlist, not a blocklist. If your feature only needs to fetch from specific domains or CDNs, maintain an explicit allowlist of allowed hostnames. Everything else is rejected. Blocklists of private IP ranges will always have gaps.
Validate the resolved IP, not the hostname. Resolve the hostname yourself before passing it to your HTTP client, then check whether the IP falls in private/reserved ranges. Reject it if it does. Then make the request — with redirect-following disabled.
Disable automatic redirect-following. Configure your HTTP client to not follow 3xx redirects automatically. Handle redirects manually so you can re-validate the destination.
Use an egress proxy. Route all outbound requests from your app through a proxy (e.g. Squid, or a cloud network policy) that enforces allowlisted destinations at the network layer. Defense in depth — your code might have a bypass, but the proxy catches it.
Strip credentials from URLs. Reject any URL with a @ in it (e.g. http://evil.com@169.254.169.254/) — some parsers will interpret the part after @ as the actual host.
Every one of these implementations does the same four things, and all four matter: it rejects non-HTTP schemes, refuses any URL containing @, resolves the hostname and checks the resolved IP against private ranges, and disables automatic redirect-following so each hop is re-validated. Drop any one of them and you've left a bypass open.
The one gap almost every implementation still has
Even the code above has a subtle weakness: DNS rebinding. Between the moment isSafeUrl() resolves the hostname and the moment your HTTP client connects, the DNS record can change. Your check sees a public IP; the actual request lands on 169.254.169.254. This is the check-then-use race, and it defeats every example in this post as written.
There are two real fixes:
- Resolve once, then connect to that exact IP. Pin the IP you validated and force the HTTP client to use it — pass the resolved address to the connection and send the original hostname only as the
Hostheader (and SNI). The client never does a second lookup, so there's no window to rebind. This is the correct fix, and it's the one most libraries make awkward. - Enforce it at the network layer too. An egress proxy or a firewall rule that blocks the entire link-local and RFC 1918 ranges outbound doesn't care what your DNS resolved to. Your application code can have a bug; the network policy still holds. Treat the code as the first layer, not the only one.
If you take one thing away: SSRF is not a string-validation problem, it's a destination problem. The question is never "does this URL look safe" — it's "what IP will this request actually reach, and am I certain it can't change between now and then."
Ready to see it end to end? The SSRF attack simulation lets you exploit a vulnerable link-preview feature, pull mock IAM credentials from a metadata endpoint, then apply the exact defenses above and watch each bypass close.
Frequently Asked Questions
Related posts
Race Conditions in Web Apps: When Two Requests Beat Your Check
Check-then-act logic that looks correct on paper breaks when two requests run at once. Here's how TOCTOU race conditions drain balances and reuse one-time codes, and how atomic operations and locks fix them.
Aug 11, 2026 · 7 min readMass Assignment: How role=admin Ends Up in Your Update
Mass assignment lets an attacker set fields you never meant to expose — like isAdmin or accountBalance — by adding them to a request body. Here's why binding whole objects is dangerous and how to allowlist fields in Node, Rails, and Django.
Aug 10, 2026 · 6 min readInsecure Deserialization: How Loading Data Becomes Running Code
Deserializing untrusted data can execute code before your app reads a single field. Here's how gadget chains work in Python pickle, Java, PHP, and Node, and why JSON with a schema is the fix.
Aug 9, 2026 · 7 min read