← Blog
·11 min read

OWASP A10 SSRF Explained for Developers

SSRF lets attackers make your server fetch internal resources — including AWS metadata credentials. This guide explains how it works and how to stop it.

Rod

Founder & Developer

Server-side request forgery (SSRF) is the newest entry in the OWASP Top 10, added in 2021 as A10. It wasn't prominent enough to make the 2017 list. By 2021, OWASP added it based on industry data and one high-profile reality check: the 2019 Capital One breach, which exposed over 100 million customer records and started with a single SSRF request to an AWS metadata endpoint.

The concept is simple. Your server fetches a URL. An attacker controls what that URL is. Your server ends up fetching something it shouldn't — internal services, cloud credentials, private admin panels. The attacker sits outside your network, but your server is inside it, so they get access through your server.

This guide shows you exactly how SSRF works, what the vulnerable code looks like, how to fix it, and why this class of vulnerability is getting more dangerous as cloud-native architectures become the norm.


How SSRF Works — The Attack Flow

Here's the pattern in plain terms.

Your application has a feature that takes a URL from the user and fetches it server-side. Maybe it's a link preview generator. Maybe it's a webhook validator. Maybe it's an image proxy that resizes URLs before serving them. Maybe it's an import feature that reads a user-supplied CSV link.

These are all legitimate features. The vulnerability isn't in the feature itself — it's in what happens when the URL points somewhere unexpected.

The attack flow:

  1. User submits a URL to your API
  2. Your server calls fetch(url) — or axios.get(url), or any HTTP client
  3. Attacker's URL points to http://169.254.169.254/latest/meta-data/iam/security-credentials/ instead of a real website
  4. Your server, running inside AWS, fetches that URL — and AWS returns IAM credentials in plain JSON
  5. Attacker reads the response your server returns, or watches for out-of-band callbacks if you don't return the body

The AWS metadata endpoint at 169.254.169.254 is the most dangerous target because it's available on every EC2 instance and returns credentials with no authentication. But the same pattern applies to other internal targets:

  • http://localhost:6379/ — Redis, often with no auth in development configs that leaked to production
  • http://internal-admin:8080/ — an internal admin panel that assumed it was unreachable from the internet
  • http://10.0.0.1/admin — a router or internal service on the private network
  • http://kubernetes.default.svc/api/v1/secrets — Kubernetes API, if your app runs in a cluster

All of these are unreachable from the public internet. But they're reachable from inside your server. That's what makes SSRF dangerous in cloud and microservices environments — your network perimeter stops external attackers, not your own application making requests on their behalf.

Redirect-Based SSRF

A subtler variant: the attacker submits a legitimate-looking URL that redirects to an internal address.

Your server fetches https://attacker-controlled-domain.com/redirect. That URL returns a 301 to http://169.254.169.254/latest/meta-data/. If your HTTP client follows redirects automatically (most do by default), your server ends up at the internal endpoint even though the original URL looked fine.

Allowlisting the initial domain doesn't help if you follow redirects without re-validating.


Vulnerable Code — What SSRF Looks Like in Practice

Here's the most direct version of the vulnerability. A Next.js API route that fetches a user-supplied URL:

// BAD: Fetches any URL the user provides — including internal ones
export async function POST(req: Request) {
  const { url } = await req.json();
  const response = await fetch(url); // no validation whatsoever
  const data = await response.text();
  return Response.json({ content: data });
}

One request to your API with url: "http://169.254.169.254/latest/meta-data/iam/security-credentials/my-role" and you've handed over AWS credentials.

A slightly less obvious version — an image proxy that resizes images from external URLs:

// BAD: Image proxy with no URL validation
export async function GET(req: Request) {
  const { searchParams } = new URL(req.url);
  const imageUrl = searchParams.get("url") ?? "";
 
  // Fetches the URL and streams it back as an image
  // Looks safe because it's "just images" — but it's not
  const imageResponse = await fetch(imageUrl);
  const imageBuffer = await imageResponse.arrayBuffer();
 
  return new Response(imageBuffer, {
    headers: { "Content-Type": imageResponse.headers.get("Content-Type") ?? "image/jpeg" },
  });
}

This is common in real codebases. The developer thought "it's just fetching images" — but the server doesn't check whether the URL is actually an image URL. Submit http://169.254.169.254/latest/meta-data/ and the "image" response body is your IAM credentials.


Secure Code — Blocking SSRF

The fix has three layers: validate the URL before fetching, block private IP ranges, and disable automatic redirect following.

Here's a reusable URL validator you can drop into any project:

import { isIP } from "net";
 
// Private IP ranges that must never be fetched
const PRIVATE_IP_PATTERNS = [
  /^127\./,           // localhost
  /^10\./,            // RFC 1918 private range
  /^172\.(1[6-9]|2\d|3[01])\./,  // RFC 1918 private range
  /^192\.168\./,      // RFC 1918 private range
  /^169\.254\./,      // link-local (AWS metadata lives here)
  /^::1$/,            // IPv6 localhost
  /^fc00:/,           // IPv6 unique local
  /^fe80:/,           // IPv6 link-local
];
 
export function isSafeUrl(rawUrl: string): boolean {
  let parsed: URL;
  try {
    parsed = new URL(rawUrl);
  } catch {
    return false; // not a valid URL at all
  }
 
  // Only allow http and https — no file://, ftp://, gopher://, etc.
  if (!["http:", "https:"].includes(parsed.protocol)) return false;
 
  const hostname = parsed.hostname.toLowerCase();
 
  // Block obvious localhost variants
  if (hostname === "localhost" || hostname === "0.0.0.0") return false;
 
  // If the hostname is an IP address, check against private ranges
  if (isIP(hostname)) {
    return !PRIVATE_IP_PATTERNS.some((pattern) => pattern.test(hostname));
  }
 
  return true; // domain-based hostnames pass — allowlist below for stricter control
}

Now use it before every server-side fetch:

// GOOD: Validates URL before fetching — blocks internal network access
export async function POST(req: Request) {
  const { url } = await req.json();
 
  if (!isSafeUrl(url)) {
    return Response.json({ error: "Invalid URL" }, { status: 400 });
  }
 
  // Disable redirect following — re-validate each hop if you need redirects
  const response = await fetch(url, { redirect: "error" });
  const data = await response.text();
  return Response.json({ content: data });
}

For the image proxy, use an allowlist of permitted domains instead of a blocklist:

// GOOD: Domain allowlist + IP range blocking for image proxy
const ALLOWED_IMAGE_DOMAINS = [
  "images.unsplash.com",
  "cdn.yourdomain.com",
  "storage.googleapis.com",
];
 
export async function GET(req: Request) {
  const { searchParams } = new URL(req.url);
  const imageUrl = searchParams.get("url") ?? "";
 
  let parsed: URL;
  try {
    parsed = new URL(imageUrl);
  } catch {
    return new Response("Invalid URL", { status: 400 });
  }
 
  // Only allow explicitly approved domains
  if (!ALLOWED_IMAGE_DOMAINS.includes(parsed.hostname)) {
    return new Response("Domain not allowed", { status: 400 });
  }
 
  // Disable redirects — an allowed domain could redirect to an internal one
  const imageResponse = await fetch(imageUrl, { redirect: "error" });
  const imageBuffer = await imageResponse.arrayBuffer();
 
  return new Response(imageBuffer, {
    headers: { "Content-Type": "image/webp" },
  });
}

The redirect problem. Setting redirect: "error" stops your HTTP client from following any redirect automatically. If you need redirect support, follow each hop manually — parse the Location header, call isSafeUrl() again, then fetch the next URL. One validation at the start isn't enough if the first URL redirects to an internal address.


Real-World SSRF: The Capital One Breach

In July 2019, a misconfigured Web Application Firewall (WAF) running on AWS allowed an attacker to execute server-side requests to the EC2 instance metadata service.

The attacker sent a crafted request that the WAF interpreted as legitimate and forwarded to the EC2 instance. The WAF process was running with an IAM role attached. The SSRF request hit http://169.254.169.254/latest/meta-data/iam/security-credentials/ and returned the role's temporary credentials — access key ID, secret access key, and session token — in plain JSON.

With those credentials, the attacker accessed S3 buckets containing Capital One's customer data. The final count: over 106 million customer records from the US and Canada. Capital One paid a $190 million FTC settlement. The breach is publicly documented in the FTC's investigation findings.

The initial exploit — the SSRF request to the metadata endpoint — was one HTTP request with a crafted URL. The damage was proportional to how much access the IAM role had, not to how sophisticated the initial attack was.

Two lessons here. First: SSRF is not a theoretical vulnerability. Second: cloud metadata endpoints are the highest-value target in any SSRF scenario because they hand over credentials, not just data.

GitLab's CVE-2021-22214 is a useful second example. The SSRF vulnerability allowed unauthenticated attackers to make HTTP requests from the GitLab server to internal services. It was rated Critical (CVSS 8.6) and required no authentication to exploit — just a crafted webhook URL submitted through the public API.


Prevention: Five Things to Do Right Now

If you're scanning your codebase or reviewing a PR, here are the specific controls that block SSRF:

1. Validate URLs Before Fetching

Never pass user-controlled strings directly to fetch(), axios.get(), got(), or any HTTP client. Always parse and validate first. The isSafeUrl() function above is a starting point — adapt it to your stack.

2. Use Domain Allowlists, Not Blocklists

Blocklists miss things. New internal services get added. New attack vectors emerge. An allowlist that only permits images.yourdomain.com and cdn.cloudfront.net can't be bypassed by finding a domain you forgot to block.

3. Disable Automatic Redirect Following

// Node.js fetch — disable redirects
const response = await fetch(url, { redirect: "error" });
 
// axios — disable redirects
const response = await axios.get(url, { maxRedirects: 0 });

If your feature genuinely needs to follow redirects, build a loop that calls isSafeUrl() on each Location header before following it.

4. Enable IMDSv2 on AWS

IMDSv2 (Instance Metadata Service v2) requires a session token that you get by making a PUT request first. A simple SSRF GET request to 169.254.169.254 can't get the token in a single hop. This is the AWS-level mitigation — it doesn't fix your code, but it limits the blast radius.

Enable it in your EC2 launch configuration:

# Require IMDSv2 — PUT request required before GET requests work
aws ec2 modify-instance-metadata-options \
  --instance-id i-1234567890abcdef0 \
  --http-tokens required \
  --http-endpoint enabled

For Terraform, set http_tokens = "required" in your aws_instance resource's metadata_options block.

5. Add Network-Level Controls

Application-level validation is your first line of defense. Network-level controls are the backstop. Configure your VPC or firewall rules to block outbound requests from your app servers to the metadata IP range (169.254.169.254/32) and to your internal private subnets.

Security in depth: if the code check fails, the network rule catches it.


Where SSRF Hides in Your Codebase

When we scan repos at Data Hogo, SSRF risk shows up most often in these patterns:

Webhook registration endpoints. You let users register a webhook URL that your server calls on events. Any user-controlled URL in a server-side HTTP call is a potential SSRF vector if it isn't validated.

Link preview generators. The app fetches the URL to extract Open Graph metadata. Fast to build, easy to forget to validate.

PDF and screenshot services. Headless browsers (Puppeteer, Playwright) that render user-supplied URLs are SSRF with extra steps — the rendered page can also exfiltrate internal content through embedded resources.

Import / data sync features. "Paste a URL to a CSV file and we'll import it." The server fetches the CSV. The CSV URL could be http://internal-service/sensitive-data.csv.

Third-party integrations. Fetching a logo from a user-supplied domain. Validating a URL from a form field. Any place where a user provides a string that becomes a network destination.

The SSRF vulnerability encyclopedia entry covers the full taxonomy including out-of-band SSRF techniques. If you're building features that accept URLs, also read SSRF in API routes for patterns specific to REST and GraphQL APIs, and cloud metadata SSRF for the AWS, GCP, and Azure-specific metadata endpoints you need to block.


Checking Your Code for SSRF

The Data Hogo scanner checks for SSRF-prone patterns: calls to fetch(), axios.get(), got(), request(), and similar HTTP clients where the URL argument traces back to user input — request body, query parameters, path parameters, or external data sources.

We flag these as SSRF risk findings with the specific file and line, the HTTP client used, and whether the URL appears to be validated before use. Fixing a flagged finding is usually adding a validation step — which you can do in minutes once you know where to look.

Scan your repo free to find SSRF-prone patterns in your codebase.


Frequently Asked Questions

What is server-side request forgery (SSRF)?

SSRF is a vulnerability where an attacker tricks your server into making HTTP requests to a location the attacker controls. Your server fetches a URL that looks legitimate but actually points to an internal service — like the AWS metadata API at 169.254.169.254, a Redis instance, or an internal admin panel. The server does the fetching, so firewall rules that block external users don't help. It's OWASP A10:2021.

Why is AWS metadata at 169.254.169.254 dangerous in SSRF?

The AWS Instance Metadata Service (IMDS) is a link-local endpoint available to any process running on an EC2 instance. It returns IAM role credentials — access key, secret key, and session token — in plain JSON with no authentication. If your server fetches attacker-controlled URLs and has no block on 169.254.169.254, one request hands over credentials that can access your entire AWS account. Enabling IMDSv2 mitigates this by requiring a session token that a simple GET request can't provide.

What is the difference between basic SSRF and blind SSRF?

In basic SSRF, the server returns the fetched content back to the attacker — so they can read the response from the internal resource directly. In blind SSRF, the server makes the request but doesn't return the response body. The attacker can still confirm internal hosts exist (by watching response timing or triggering out-of-band callbacks), but can't read the data directly. Blind SSRF is harder to exploit but still useful for mapping internal infrastructure.

How do I block SSRF in a Node.js or Next.js application?

Parse the URL before fetching it. Check that the hostname is not a private IP range (10.x.x.x, 172.16-31.x.x, 192.168.x.x, 127.x.x.x) and not a link-local address (169.254.x.x). Use an allowlist of permitted domains instead of a blocklist. Disable automatic HTTP redirect following, or re-validate the resolved hostname after each redirect. Never pass user-controlled strings directly to fetch(), axios, or any HTTP client.

Did SSRF cause the Capital One data breach?

Yes. The 2019 Capital One breach started with an SSRF vulnerability in a misconfigured Web Application Firewall. The attacker used it to reach the AWS metadata endpoint at 169.254.169.254, retrieved IAM role credentials, and used those credentials to access over 100 million customer records stored in S3. It's the most cited real-world SSRF example because the impact — 106 million records, a $190 million FTC settlement — is proportional to how simple the initial exploit was.

OWASPSSRFserver-side request forgerycloud securityAWSAPI securitysecurityguides