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
Senior JavaScript security consultants with 10+ years of experience.