SSRF Prevention in Node.js and Next.js: Server-Side Request Forgery Defense Guide [2026]
Introduction
Your Next.js Server Component calls fetch("https://api.example.com/data"). An API route proxies user-supplied URLs. A PDF generator accepts a remote image link. Any of these can become an SSRF vector — and the consequences range from internal network scanning to cloud metadata credential theft.
Server-Side Request Forgery (SSRF) landed in the OWASP Top 10 (A10:2021) because cloud infrastructure and server-side rendering made it both more common and more dangerous. In 2026, with Next.js Server Components fetching data server-side by default, the attack surface is larger than ever.
In this guide, you'll learn:
- What SSRF looks like in real Next.js and Node.js applications
- Why URL validation is harder than it looks (URL parsing pitfalls, DNS rebinding, redirect chains)
- A production-grade defense strategy with code you can copy into your project
- How to test your SSRF defenses with practical tools
What is SSRF?
Server-Side Request Forgery occurs when an attacker tricks your server into making requests to unintended destinations. The server has network access the attacker doesn't — internal services, cloud metadata endpoints, databases, container orchestrators — and SSRF turns your server into a proxy.
Simple example in a Next.js API route:
// app/api/proxy/route.ts — VULNERABLE
export async function GET(request: NextRequest) {
const url = request.nextUrl.searchParams.get("url");
const response = await fetch(url as string);
const data = await response.text();
return new Response(data);
}
An attacker calls /api/proxy?url=http://169.254.169.254/latest/meta-data/ and gets your AWS instance credentials back.
Same vulnerability in a Server Component:
// app/page.tsx — VULNERABLE if url comes from user input
export default async function Page({ searchParams }) {
const { url } = await searchParams;
const res = await fetch(url);
const data = await res.json();
return <pre>{JSON.stringify(data, null, 2)}</pre>;
}
Next.js Server Components fetch data at request time on the server. Any url parameter passed to fetch() that originates from user input — query params, form data, headers, cookies — is a potential SSRF vector.
Attack Vectors: What Attackers Target
1. Cloud Metadata Endpoints
The most damaging SSRF targets. Cloud providers expose instance metadata at well-known IPs:
| Provider | Metadata Endpoint | What's Exposed |
|-----------|--------------------------------|------------------------------------------|
| AWS | http://169.254.169.254/latest/meta-data/ | IAM credentials, instance ID, region |
| GCP | http://metadata.google.internal/computeMetadata/v1/ | Service account tokens, project info |
| Azure | http://169.254.169.254/metadata/instance?api-version=2021-02-01 | Managed identity tokens, VM config |
| DigitalOcean | http://169.254.169.254/metadata/v1.json | Droplet metadata, user data |
2. Internal Network Scanning
# Attacker probes for internal services through your proxy
http://localhost:3000/
http://127.0.0.1:3000/
http://[::1]:3000/ # IPv6 loopback
http://0.0.0.0:3000/ # All interfaces
http://10.0.0.1:9200/ # Elasticsearch in private subnet
http://192.168.1.1:5432/ # PostgreSQL in VPC
http://172.16.0.1:6379/ # Redis in Docker bridge
3. URL Obfuscation Techniques
Attackers don't send http://169.254.169.254/ in plain text. They use:
# Decimal IP
http://2852039166/ # 169.254.169.254 in decimal
# Octal IP
http://0251.0376.0251.0376/ # 169.254.169.254 in octal
# Hex IP
http://0xA9.0xFE.0xA9.0xFE/ # 169.254.169.254 in hex
# Mixed radix
http://0251.254.0xA9.376/
# DNS tricks
http://169.254.169.254.nip.io/ # DNS resolver that returns the IP
http://1.1.1.1.nip.io:80@evil.com/ # Credential-style URL confusion
# Unicode normalization
http://①②⑨.②⑤④.①⑥⑨.②⑤④/ # Unicode equivalents (old trick, still works on some parsers)
# Redirect chains
https://open-redirect.example.com/redirect?to=http://169.254.169.254/latest/meta-data/
4. DNS Rebinding
The most advanced SSRF bypass. The attacker controls a domain that initially resolves to a safe IP (passing your validation), then after the validation check, switches the DNS record to an internal IP.
Time T: evil.com → 1.2.3.4 (safe — passes allowlist check)
Time T+1: evil.com → 169.254.169.254 (resolve() called during fetch, not validation)
The server validates evil.com against the allowlist when T=0, but fetch() calls dns.lookup() at T+1 — getting the internal IP. This is why validating before the fetch isn't enough without additional safeguards.
Defense Strategy: Defense in Depth
SSRF prevention requires layered defenses. No single check is sufficient. Here's the complete strategy:
Layer 1: URL Validation and Allowlisting
If you know the expected hosts, allowlist them. This is the strongest defense.
// lib/ssrf/allowlist.ts
const ALLOWED_HOSTS = new Set([
"api.example.com",
"cdn.example.com",
"api.stripe.com",
"api.github.com",
]);
export function validateAgainstAllowlist(url: string): {
valid: boolean;
error?: string;
} {
try {
const parsed = new URL(url);
if (!ALLOWED_HOSTS.has(parsed.hostname)) {
return { valid: false, error: `Host '${parsed.hostname}' is not allowlisted` };
}
return { valid: true };
} catch {
return { valid: false, error: "Invalid URL" };
}
}
When allowlisting isn't possible (user provides arbitrary URLs, like in a webhook proxy or link previewer), use denylists with strict validation.
Layer 2: URL Parsing — Don't Trust Your URL Parser
The URL constructor in Node.js is good but not infallible. Some attacks exploit differences between how new URL() and fetch() resolve addresses.
Critical: Always resolve the final IP, not just the hostname.
// lib/ssrf/validate.ts
import { lookup } from "net";
import { isIP } from "net";
const PRIVATE_RANGES = [
// IPv4 private ranges
{ prefix: "10.", type: "ipv4" },
{ prefix: "127.", type: "ipv4" },
{ prefix: "169.254.", type: "ipv4" },
{ prefix: "172.16.", type: "ipv4" },
{ prefix: "192.168.", type: "ipv4" },
{ prefix: "0.", type: "ipv4" },
// IPv6
{ prefix: "::1", type: "ipv6" },
{ prefix: "::", type: "ipv6" },
{ prefix: "fc", type: "ipv6" }, // fc00::/7 — unique local
{ prefix: "fd", type: "ipv6" }, // fd00::/7 — unique local
{ prefix: "fe80", type: "ipv6" }, // link-local
];
function isPrivateIP(ip: string): boolean {
return PRIVATE_RANGES.some((range) => ip.startsWith(range.prefix));
}
export async function resolveAndValidate(url: URL): Promise<{
valid: boolean;
error?: string;
resolvedIP?: string;
}> {
// Step 1: Reject if no protocol or non-http(s)
if (!["http:", "https:"].includes(url.protocol)) {
return { valid: false, error: "Only http and https protocols are allowed" };
}
// Step 2: Reject if credentials present (bypass attempt)
if (url.username || url.password) {
return { valid: false, error: "URL credentials are not allowed" };
}
// Step 3: Reject IP-form hosts that are private
const hostname = url.hostname;
if (isIP(hostname)) {
if (isPrivateIP(hostname)) {
return { valid: false, error: "Private IP addresses are not allowed" };
}
return { valid: true, resolvedIP: hostname };
}
// Step 4: Resolve DNS — THIS is where DNS rebinding can happen
const resolvedIP = await new Promise<string>((resolve, reject) => {
lookup(hostname, { family: 4 }, (err, address) => {
if (err) reject(err);
else resolve(address);
});
});
if (isPrivateIP(resolvedIP)) {
return {
valid: false,
error: `Resolved IP '${resolvedIP}' is a private address`,
resolvedIP,
};
}
// Step 5: Check against known cloud metadata IPs (belt and suspenders)
if (resolvedIP === "169.254.169.254") {
return { valid: false, error: "Cloud metadata endpoint is blocked", resolvedIP };
}
return { valid: true, resolvedIP };
}
Pitfall: lookup() uses the system resolver, which may respect /etc/hosts. If an attacker has any ability to modify the host file (compromised container, shared hosting), DNS resolution isn't trustworthy. In that case, use a DNS-over-HTTPS resolver for critical paths.
Layer 3: Block Redirect Chains
SSRF often uses an open redirect as a stepping stone. Your server validates the initial URL, fetch() follows a 302, and suddenly you're hitting http://169.254.169.254/.
// lib/ssrf/fetch.ts
import { resolveAndValidate } from "./validate";
interface SafeFetchOptions {
maxRedirects?: number;
timeout?: number;
validateRedirect?: boolean;
}
export async function safeFetch(
url: string,
options: SafeFetchOptions = {}
): Promise<Response> {
const {
maxRedirects = 0, // Don't follow redirects by default
timeout = 5000,
validateRedirect = true,
} = options;
const parsed = new URL(url);
// Validate the initial URL
const initialCheck = await resolveAndValidate(parsed);
if (!initialCheck.valid) {
throw new Error(`SSRF check failed: ${initialCheck.error}`);
}
// Use AbortController for timeout
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeout);
try {
const response = await fetch(url, {
signal: controller.signal,
redirect: "manual", // DON'T auto-follow redirects
});
// Manually validate redirect targets
if (response.status >= 300 && response.status < 400) {
const location = response.headers.get("location");
if (!location) {
throw new Error("Redirect without Location header");
}
// Validate the redirect target
const redirectUrl = new URL(location, url); // resolve relative redirects
const redirectCheck = await resolveAndValidate(redirectUrl);
if (!redirectCheck.valid) {
throw new Error(`Redirect target blocked: ${redirectCheck.error}`);
}
if (maxRedirects <= 0) {
// Return redirect info without following
return response;
}
// Recursively follow with decremented counter
return safeFetch(location, {
...options,
maxRedirects: maxRedirects - 1,
});
}
return response;
} finally {
clearTimeout(timer);
}
}
Key decision: Set redirect: "manual" on all internal fetch() calls that process user-supplied URLs. This gives you control over which redirects to follow.
Layer 4: DNS Rebinding Protection
DNS rebinding is hard to prevent with validation alone because the DNS resolution happens twice — once in your validator, once in fetch(). If the DNS record changes between those calls, your validation is useless.
Solution A: Pin the IP before fetching
// lib/ssrf/pinned-fetch.ts
import { createConnection } from "net";
import { request } from "http";
import { request as httpsRequest } from "https";
export async function pinnedFetch(url: string): Promise<Response> {
const parsed = new URL(url);
// Resolve DNS NOW and validate
const ip = await resolveHostname(parsed.hostname);
if (isPrivateIP(ip)) {
throw new Error("Blocked private IP");
}
// "Pin" the IP by connecting directly, bypassing DNS
// Fetch with explicit IP and Host header
const actualUrl = `${parsed.protocol}//${ip}${parsed.pathname}${parsed.search}`;
const headers = {
...(parsed.hostname && { Host: parsed.hostname }),
};
const controller = new AbortController();
const response = await fetch(actualUrl, {
headers,
signal: controller.signal,
});
return response;
}
This approach resolves DNS once and connects directly to the IP, bypassing the second DNS resolution entirely. DNS rebinding becomes impossible because there's only one DNS lookup.
Solution B: Use agent with custom DNS
import { Resolver } from "dns/promises";
import { Agent } from "http";
const dnsResolver = new Resolver();
dnsResolver.setServers(["1.1.1.1"]); // Use DNS-over-HTTPS upstream
class SSRFGuardAgent extends Agent {
async createConnection(options: any, cb: Function) {
// Always resolve through our safe DNS
const { hostname } = options;
const { address } = await dnsResolver.resolve4(hostname);
if (isPrivateIP(address)) {
cb(new Error(`SSRF: blocked private IP ${address}`));
return;
}
options.host = address; // Connect to resolved IP
options.servername = hostname; // Keep SNI for HTTPS
return super.createConnection(options, cb);
}
}
Layer 5: Network-Level Blocking (Defense in Depth)
Code is good. But network-level blocking is the insurance policy if your code has a bug.
For Docker deployments:
# Block outbound to metadata endpoints at the network level
# Run container with iptables rules via --cap-add=NET_ADMIN
RUN iptables -A OUTPUT -d 169.254.169.254 -j DROP
RUN iptables -A OUTPUT -d 169.254.169.254/32 -j DROP
# Block link-local and private ranges for outbound
RUN iptables -A OUTPUT -d 10.0.0.0/8 -j DROP
RUN iptables -A OUTPUT -d 172.16.0.0/12 -j DROP
RUN iptables -A OUTPUT -d 192.168.0.0/16 -j DROP
RUN iptables -A OUTPUT -d 127.0.0.0/8 -j DROP
For Kubernetes, use NetworkPolicies:
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: deny-metadata
spec:
podSelector:
matchLabels:
app: my-app
policyTypes:
- Egress
egress:
- to:
- ipBlock:
cidr: 0.0.0.0/0
except:
- 10.0.0.0/8
- 172.16.0.0/12
- 192.168.0.0/16
- 169.254.0.0/16
Production-Ready SSRF Validation Library
Let me put it all together in a reusable module you can drop into any Next.js project:
// lib/ssrf/index.ts
import { lookup } from "net";
import { isIP } from "net";
import { URL } from "url";
// --- Configuration ---
const BLOCKED_IPS = new Set([
"0.0.0.0",
"127.0.0.1",
"::1",
"::",
"169.254.169.254",
]);
const PRIVATE_RANGES = [
{ ip: 0x0a000000, mask: 0xff000000, name: "10.0.0.0/8" }, // 10.0.0.0/8
{ ip: 0x7f000000, mask: 0xff000000, name: "127.0.0.0/8" }, // 127.0.0.0/8
{ ip: 0xa9fe0000, mask: 0xffff0000, name: "169.254.0.0/16" }, // 169.254.0.0/16
{ ip: 0xac100000, mask: 0xfff00000, name: "172.16.0.0/12" }, // 172.16.0.0/12
{ ip: 0xc0a80000, mask: 0xffff0000, name: "192.168.0.0/16" }, // 192.168.0.0/16
{ ip: 0x00000000, mask: 0x00000000, name: "0.0.0.0/32" }, // 0.0.0.0/32
];
function ipv4ToInt(ip: string): number {
return ip.split(".").reduce((acc, octet) => (acc << 8) + parseInt(octet, 10), 0) >>> 0;
}
function isPrivateIP(ip: string): boolean {
if (BLOCKED_IPS.has(ip)) return true;
if (isIP(ip) === 4) {
const intIP = ipv4ToInt(ip);
return PRIVATE_RANGES.some((range) => (intIP & range.mask) === range.ip);
}
// IPv6 checks
if (isIP(ip) === 6) {
const normalized = ip.toLowerCase();
if (normalized.startsWith("fc") || normalized.startsWith("fd")) return true; // ULA
if (normalized.startsWith("fe80")) return true; // Link-local
if (normalized === "::1") return true; // Loopback
}
return false;
}
export interface SSRFResult {
valid: boolean;
error?: string;
hostname: string;
resolvedIP?: string;
}
export async function validateURL(input: string): Promise<SSRFResult> {
// 1. Parse URL
let parsed: URL;
try {
parsed = new URL(input);
} catch {
return { valid: false, error: "Invalid URL", hostname: input };
}
// 2. Protocol check
if (!["http:", "https:"].includes(parsed.protocol)) {
return { valid: false, error: "Only http/https URLs allowed", hostname: parsed.hostname };
}
// 3. Reject URLs with embedded credentials
if (parsed.username || parsed.password) {
return { valid: false, error: "URL must not contain credentials", hostname: parsed.hostname };
}
// 4. Reject overly long URLs (potential DNS tunneling)
if (parsed.hostname.length > 253) {
return { valid: false, error: "Hostname too long", hostname: parsed.hostname };
}
const hostname = parsed.hostname;
// 5. Direct IP check
if (isIP(hostname)) {
if (isPrivateIP(hostname)) {
return { valid: false, error: "Private IP range blocked", hostname, resolvedIP: hostname };
}
return { valid: true, hostname, resolvedIP: hostname };
}
// 6. DNS resolution
try {
const resolvedIP = await new Promise<string>((resolve, reject) => {
lookup(hostname, { family: 4, hints: 0 }, (err, address) => {
if (err) reject(err);
else resolve(address);
});
});
if (isPrivateIP(resolvedIP)) {
return {
valid: false,
error: `Resolved to blocked IP range: ${resolvedIP}`,
hostname,
resolvedIP,
};
}
return { valid: true, hostname, resolvedIP };
} catch (error: any) {
return { valid: false, error: `DNS resolution failed: ${error.message}`, hostname };
}
}
Usage in a Next.js API route:
// app/api/fetch-image/route.ts
import { NextRequest, NextResponse } from "next/server";
import { validateURL } from "@/lib/ssrf";
export async function GET(request: NextRequest) {
const imageUrl = request.nextUrl.searchParams.get("url");
if (!imageUrl) {
return NextResponse.json({ error: "Missing 'url' parameter" }, { status: 400 });
}
// SSRF validation
const validation = await validateURL(imageUrl);
if (!validation.valid) {
return NextResponse.json(
{ error: `URL rejected: ${validation.error}` },
{ status: 403 }
);
}
// Safe fetch with redirect control
try {
const response = await fetch(imageUrl, {
redirect: "manual",
signal: AbortSignal.timeout(5000),
});
// Validate any redirect target
if (response.status >= 300 && response.status < 400) {
const location = response.headers.get("location");
if (location) {
const redirectValidation = await validateURL(new URL(location, imageUrl).href);
if (!redirectValidation.valid) {
return NextResponse.json(
{ error: `Redirect rejected: ${redirectValidation.error}` },
{ status: 403 }
);
}
// Follow the redirect
return NextResponse.redirect(new URL(location, imageUrl));
}
}
// Return the content
const buffer = await response.arrayBuffer();
return new NextResponse(buffer, {
headers: {
"Content-Type": response.headers.get("content-type") || "application/octet-stream",
"Cache-Control": "public, max-age=86400",
},
});
} catch (error) {
return NextResponse.json({ error: "Fetch failed" }, { status: 502 });
}
}
Usage in a Server Component:
// app/preview/page.tsx
import { validateURL } from "@/lib/ssrf";
export default async function LinkPreviewPage({
searchParams,
}: {
searchParams: Promise<{ url: string }>;
}) {
const { url } = await searchParams;
if (!url) {
return <p>Missing URL</p>;
}
// Validate before fetching — even in Server Components
const validation = await validateURL(url);
if (!validation.valid) {
return <p className="text-red-500">Blocked: {validation.error}</p>;
}
// Only now fetch the external content
const response = await fetch(url, { redirect: "manual", signal: AbortSignal.timeout(5000) });
const html = await response.text();
// ... render preview ...
}
SSRF in Server Actions
Server Actions also make fetch calls and can receive URLs from form submissions:
// app/actions.ts
"use server";
import { validateURL } from "@/lib/ssrf";
export async function importExternalData(formData: FormData) {
const url = formData.get("sourceUrl") as string;
// SSRF validation is mandatory here
const validation = await validateURL(url);
if (!validation.valid) {
return { error: validation.error };
}
const response = await fetch(url, { redirect: "manual" });
const data = await response.json();
// ... process data ...
}
SSRF from Internal Services
SSRF isn't always about user-supplied URLs. Any service that your backend calls with an attacker-controlled parameter is a vector:
- Webhook receivers that echo URLs back in responses
- DNS resolvers that fetch TXT records from attacker domains
- SSO/OAuth flows that redirect to arbitrary URLs
- File processors that fetch remote assets (PDF generators, image resizers)
- Headless browsers used for screenshot services
Checklist: Audit your app for these patterns:
- [ ] All
fetch()calls that accept a URL parameter from user input are validated - [ ] Redirects are set to
"manual"and each redirect target is re-validated - [ ] DNS resolution is done before the fetch, not after
- [ ] File downloads from user-provided URLs reject redirect chains
- [ ] Webhook handlers don't proxy the webhook payload URL without validation
- [ ] Server Components that read
searchParamsand callfetch()validate the URL - [ ] Server Actions that accept URLs in form data validate them
- [ ] Metadata proxy endpoints are allowlisted, not denylisted, where possible
Testing Your SSRF Defenses
Dev testing with a local server
# Start a simple internal listener to test if SSRF is blocked
python3 -m http.server 9999
# Try accessing it through your app
curl "http://localhost:3000/api/fetch-image?url=http://127.0.0.1:9999/"
curl "http://localhost:3000/api/fetch-image?url=http://0.0.0.0:9999/"
curl "http://localhost:3000/api/fetch-image?url=http://[::1]:9999/"
Automated testing
// tests/ssrf.test.ts
import { validateURL } from "@/lib/ssrf";
describe("SSRF validation", () => {
const privateIPs = [
"http://127.0.0.1:3000/",
"http://10.0.0.1:9200/",
"http://169.254.169.254/latest/meta-data/",
"http://192.168.1.1:5432/",
"http://0.0.0.0:3000/",
"http://[::1]:3000/",
];
it.each(privateIPs)("blocks private IP: %s", async (url) => {
const result = await validateURL(url);
expect(result.valid).toBe(false);
});
const allowedURLs = [
"https://api.example.com/v1/data",
"https://cdn.example.com/image.png",
"http://httpbin.org/get",
];
it.each(allowedURLs)("allows public URLs: %s", async (url) => {
const result = await validateURL(url);
expect(result.valid).toBe(true);
});
it("rejects URLs with embedded credentials", async () => {
const result = await validateURL("http://user:pass@evil.com/");
expect(result.valid).toBe(false);
});
it("rejects non-http protocols", async () => {
const result = await validateURL("file:///etc/passwd");
expect(result.valid).toBe(false);
});
it("rejects IP-based localhost alternatives", async () => {
// Decimal representation
const result = await validateURL(`http://${ipToDecimal("127.0.0.1")}/`);
expect(result.valid).toBe(false);
});
});
Production testing with SSRF Map
Use SSRF Map or a similar tool to walk through common bypass techniques:
# Install SSRF Map
git clone https://github.com/swisskyrepo/SSRFmap
cd SSRFmap && pip install -r requirements.txt
# Test your endpoint
python3 ssrfmap.py -t "http://localhost:3000/api/proxy?url=URLHERE"
Third-Party Libraries
If you don't want to roll your own, these libraries handle SSRF validation:
| Library | Description | |---------|-------------| | ssrf-req-filter | Intercepts Node.js http/https requests and blocks private IPs | | ip-range-check | Check if an IP falls within CIDR ranges | | axios-ssrf | Axios adapter that validates URLs before connecting | | undici | Node.js HTTP client (used by fetch) — can use custom dispatchers |
Example with ssrf-req-filter:
import { SSRFGuard } from "ssrf-req-filter";
import http from "http";
import https from "https";
// Patch global http/https modules
SSRFGuard.protect();
// Now ALL http/https requests are validated
// Private IPs, metadata endpoints, etc. are blocked automatically
// For Next.js, you'd patch in next.config.ts or at app initialization
Caveat: Patching the global http/https module works for http.request() but NOT for fetch() which uses undici internally. For fetch() you need explicit validation (our validateURL() approach above).
Summary: SSRF Prevention Checklist
- [ ] Allowlist where possible instead of denylist — it's the only truly safe approach
- [ ] Never trust user-supplied URLs — validate all of them against both DNS and IP
- [ ] Resolve DNS and validate IP before making the actual request
- [ ] Set
redirect: "manual"on all fetches that involve user-controlled URLs - [ ] Validate every redirect target individually when following redirect chains
- [ ] Pin IPs to prevent DNS rebinding on critical paths (metadata proxies, webhooks)
- [ ] Set request timeouts — SSRF can hang on internal hosts that accept TCP but don't respond
- [ ] Use network policies (iptables, Kubernetes NetworkPolicy, AWS Security Groups) as a safety net
- [ ] Log blocked attempts — unexpected SSRF blocks are often the first sign of reconnaissance
- [ ] Test your defenses with the exact obfuscation techniques attackers use
SSRF is the vulnerability that keeps appearing in CTOs' nightmares because a single unvalidated URL can expose an entire cloud infrastructure. In the Next.js era where Server Components fetch data by default, every developer working with server-side code needs SSRF defense as a core skill — not a niche concern.
Next week: We'll dive into GraphQL security — batching attacks, depth limiting, authorization in resolvers, and production hardening for Apollo and Yoga servers. Subscribe below so you don't miss it.
JS Security Audit
Audits led by a senior JavaScript security engineer with 10+ years of experience.