SSRF: Server-Side Request Forgery, Attacks and Defenses
SSRF turns your own server into the attacker's proxy, and in the cloud, one image-fetch feature can hand over your IAM credentials. Here is how it works and how to shut every door.
Backend and API engineers who let users hand them a URL, to fetch an avatar, render a webhook, generate a PDF, or build a link preview. If your code calls `fetch(userInput)` anywhere, this article is about a hole you almost certainly have. No prior security background needed; we build from zero.
Server-Side Request Forgery (SSRF) is a vulnerability where an attacker tricks *your* server into making an HTTP request *on their behalf*. They cannot reach your internal network, but your server can. So they make your server do the reaching. The request now carries your server's identity, its network position, and crucially in the cloud, its credentials.
SSRF sits at A10 in the OWASP API Security Top 10 and is widely considered one of the most dangerous modern API risks, precisely because the blast radius in cloud environments is enormous. The 2019 Capital One breach, over 100 million customer records, was, at its core, an SSRF that reached an AWS metadata endpoint and walked out with IAM credentials.
The mental model: tricking the mailroom clerk
SSRF is convincing a trusted insider to fetch something for you that you were never allowed to touch yourself.
Picture a corporate mailroom. You, an outsider, are not allowed past the lobby. But the mailroom clerk can walk anywhere in the building. So you hand the clerk a note: "Please go to office 169 and bring me whatever is on the desk." The clerk, trusting the request came through normal channels, fetches the confidential file from a room you could never enter, and hands it to you in the lobby.
You, stuck in the lobbyAttacker on the public internet
The trusted mailroom clerkYour application server
A handwritten note with a room numberA user-supplied URL parameter
The locked back officesInternal services, databases, metadata API
The confidential file handed backIAM credentials / internal response
SSRF maps cleanly onto a social-engineering con against a trusted insider.
The picture: SSRF reaching cloud metadata
Every cloud VM has a magic IP, 169.254.169.254, that only it can reach. Ask that address the right path and it returns the temporary IAM credentials attached to the instance. It is meant for the workload itself. An SSRF lets an outsider borrow that privilege.
An image-fetch feature becomes a credential-exfiltration channel. The attacker never touches the metadata IP directly, the app server does.
1
Find the sink
The attacker spots a feature that fetches a URL they control, a link-preview, avatar import, or webhook tester.
2
Point it inward
Instead of a normal URL, they submit `http://169.254.169.254/latest/meta-data/iam/security-credentials/`.
3
Server obeys
Your app server, sitting on the cloud VM, happily makes that request, it is the only thing that can.
4
Read the role name
The metadata API returns the IAM role name attached to the instance.
5
Grab the keys
A second fetch to `.../security-credentials/<role>` returns an AccessKeyId, SecretAccessKey, and SessionToken.
6
Walk out the front door
Those keys are valid AWS credentials. The attacker now calls S3, RDS, or whatever the role permits, from their own laptop.
Blind vs non-blind SSRF
**Non-blind** SSRF echoes the fetched response back to the attacker (a link preview shows the body), they read credentials directly. **Blind** SSRF returns nothing useful, but the attacker still controls *where* your server connects: they probe internal ports by timing, trigger internal-only state-changing endpoints, or exfiltrate via a DNS/HTTP callback they control. Blind does not mean safe.
Where SSRF hides
SSRF is not one feature, it is a *shape*. Anywhere your server dereferences a value that ultimately came from a user, you have a candidate. The usual suspects:
Sink
Why it bites
Image / avatar fetchers
"Import from URL" pulls an arbitrary address server-side; the response is often shown back.
Webhooks
User registers a callback URL; your server POSTs to it, point it at internal services.
PDF / screenshot generators
Headless browsers render user HTML; an `<img src>` or iframe to 169.254.169.254 leaks into the PDF.
URL previews / unfurlers
Chat and CMS tools fetch a page to show a card, classic non-blind SSRF.
File / document importers
XML, SVG, and Office formats can embed remote entities that the parser fetches.
Webhook / integration testers
"Send a test request" features are SSRF by design unless egress is locked down.
Common SSRF sinks and why each one is dangerous.
The vulnerable endpoint
Here is a link-preview endpoint that looks completely reasonable. It is wide open. The user hands us a URL, we fetch it, we return the title. Nothing stops that URL from pointing inward.
preview.vulnerable.ts
typescript
import express from"express";
const app = express();
// DO NOT SHIP THIS. Textbook SSRF.
app.get("/preview", async (req, res) => {
const target = String(req.query.url);
// We blindly fetch whatever the user gave us.const upstream = awaitfetch(target);
const body = await upstream.text();
// ...and echo it straight back (non-blind SSRF).
res.json({ url: target, body });
});
An attacker calls /preview?url=http://169.254.169.254/latest/meta-data/iam/security-credentials/ and reads your role name out of the JSON. One more request and they have live credentials. The fix is not a single line, SSRF defense is layered.
The hardened endpoint
The non-negotiable rule: allow-list, never deny-list. A deny-list tries to enumerate every bad address and always loses to an encoding you did not think of. An allow-list says "only these hosts, only these schemes" and rejects everything else by default. We also have to defend against DNS rebinding: validating a hostname and *then* fetching it lets an attacker return a safe IP for the validation lookup and a private IP for the real fetch. The defense is resolve-then-validate-then-connect to the resolved IP.
preview.hardened.ts
typescript
import express from"express";
import dns from"node:dns/promises";
import net from"node:net";
import ipaddr from"ipaddr.js";
const app = express();
// 1. Allow-list of hosts you actually intend to reach.const ALLOWED_HOSTS = newSet(["images.example.com", "cdn.example.com"]);
// 2. Reject any IP that is private, loopback, or link-local.functionisForbiddenIp(ip: string): boolean {
const addr = ipaddr.parse(ip);
const range = addr.range(); // "private" | "loopback" | "linkLocal" | ...return [
"private",
"loopback",
"linkLocal", // 169.254.0.0/16 -> the metadata IP lives here"uniqueLocal",
"unspecified",
"reserved",
"carrierGradeNat",
].includes(range);
}
app.get("/preview", async (req, res) => {
const raw = String(req.query.url);
// 3. Parse and pin the scheme. No file://, gopher://, etc.let url: URL;
try {
url = newURL(raw);
} catch {
return res.status(400).json({ error: "malformed url" });
}
if (url.protocol !== "https:") {
return res.status(400).json({ error: "only https allowed" });
}
// 4. Allow-list the host.if (!ALLOWED_HOSTS.has(url.hostname)) {
return res.status(400).json({ error: "host not allowed" });
}
// 5. Resolve DNS ourselves, then validate the RESOLVED ip.// This defeats DNS rebinding: we connect to the ip we checked.const { address } = await dns.lookup(url.hostname);
if (isForbiddenIp(address)) {
return res.status(400).json({ error: "resolves to a private range" });
}
// 6. Pin the connection to the validated ip and DISABLE redirects.// A 302 to 169.254.169.254 would otherwise sneak past every check.const upstream = awaitfetch(url.toString(), {
redirect: "error",
headers: { host: url.hostname },
// @ts-expect-error undici dispatcher pins the resolved address
dispatcher: pinnedDispatcher(address),
});
const body = (await upstream.text()).slice(0, 50_000);
res.json({ url: url.toString(), body });
});
// Pin every socket to the pre-validated ip so the runtime// cannot re-resolve to a different (private) address.functionpinnedDispatcher(ip: string) {
const { Agent } = require("undici");
returnnewAgent({
connect: { lookup: (_h: string, _o: unknown, cb: Function) =>
cb(null, ip, net.isIPv6(ip) ? 6 : 4) },
});
}
Why each layer matters
Drop any one layer and an attacker finds the gap. Scheme check blocks `file://`. Allow-list blocks arbitrary hosts. Resolve-then-validate blocks DNS rebinding. `redirect: "error"` blocks a redirect to the metadata IP. IP-pinning blocks the runtime from re-resolving. Together they form defense in depth, the same principle behind [zero-trust networking for beginners](/blog/zero-trust-networking-beginners).
Defend at the network layer too: IMDSv2 and egress
Application code is one layer. The cloud platform gives you two more, and you should use both, because the next SSRF bug you ship will be in a dependency, not your own handler.
Require IMDSv2, it kills the classic attack
IMDSv1 answers a plain GET, which is exactly what an SSRF can forge. **IMDSv2** requires a PUT to fetch a session token first, with a `X-aws-ec2-metadata-token-ttl-seconds` header and a low hop limit. A naive SSRF can only do GETs and cannot set custom headers, so it cannot complete the handshake. The Capital One breach would have been far harder against IMDSv2-only.
harden-imds.sh
bash
# Force IMDSv2 only and cap the hop limit to 1 so containers# on the host cannot reach the metadata endpoint indirectly.
aws ec2 modify-instance-metadata-options \
--instance-id i-0abc123 \
--http-tokens required \
--http-put-response-hop-limit 1 \
--http-endpoint enabled
# Belt and braces: block the link-local metadata IP at the host# firewall for any process that is not the cloud agent.
iptables -A OUTPUT -d 169.254.169.254 -m owner ! --uid-owner 0 -j DROP
Then add an egress firewall: by default, application servers should not be allowed to make arbitrary outbound connections at all. Route outbound traffic through a proxy or NAT that only permits the handful of external endpoints you actually need. If your image-fetcher can only reach cdn.example.com, an SSRF pointed at 169.254.169.254 simply never connects. This is where SSRF defense meets cloud IAM from first principles: least privilege on the network *and* on the role means a stolen credential opens very few doors.
Common mistakes that cost hours
Using a regex deny-list. Blocking 169.254.169.254 misses the decimal form 2852039166, the octal form 0250.0376.0251.0376, IPv6-mapped addresses, and [::ffff:169.254.169.254]. Allow-list hosts instead, deny-lists always leak.
Validating the hostname, then fetching it. Between your DNS check and the actual request, an attacker-controlled domain can return a private IP (DNS rebinding). Resolve once, validate the IP, and connect to *that* IP.
Forgetting redirects. Your allow-listed, public URL returns a 302 to http://169.254.169.254/.... If your client follows redirects, every upstream check is bypassed. Set redirect: "error".
Only blocking the metadata IP. SSRF also reaches localhost, your internal admin panel on 10.0.x.x, and 127.0.0.1:6379 Redis. Block all private, loopback, link-local, and unique-local ranges.
Trusting the scheme. Without a scheme check, file:///etc/passwd, gopher://, and dict:// open whole new attack classes. Pin to https: only.
Relying on app code alone. The next SSRF will be in a transitive dependency you never audited. IMDSv2, egress firewalls, and least-privilege roles are what save you when the code layer fails.
Takeaways
The whole article in seven lines
SSRF makes your server fetch attacker-chosen URLs, it borrows your network position and your credentials.
In the cloud the prize is 169.254.169.254: reach it and you steal IAM credentials (the Capital One breach).
Blind SSRF is still dangerous, port scanning, internal triggers, and out-of-band exfiltration need no echoed response.
Defend in the app with an allow-list (never a deny-list), https-only, resolve-then-validate, IP-pinning, and no redirects.
Defend at the platform with IMDSv2 required, a hop limit of 1, and a host firewall dropping the metadata IP.
Add an egress firewall so app servers can only reach the few external hosts they genuinely need.
Pair least-privilege IAM roles with all of the above so a leaked credential opens almost nothing.
Where to go next
SSRF is one risk in a family of input-trust failures. Build the surrounding muscles next:
Practice on the networking lab and networking + DNS lab to build intuition for private ranges, link-local addresses, and how DNS resolution really works.
Want to go deeper?
This article covers concepts taught hands-on in the Cloud Engineer and DevOps career paths, with real terminal labs, production scenarios, and structured lessons.