← Back to Blog
Node.jsNext.jsSSRFAPI SecurityServer ComponentsOWASP Top 10Backend SecurityNetwork Security

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:

tsx
// 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:

tsx
// 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

bash
# 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:

text
# 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.

text
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.

tsx
// 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.

tsx
// 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/.

tsx
// 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

tsx
// 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

tsx
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:

dockerfile
# 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:

yaml
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:

tsx
// 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:

tsx
// 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:

tsx
// 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:

tsx
// 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 searchParams and call fetch() 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

bash
# 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

tsx
// 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:

bash
# 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:

tsx
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.

Share this article:TwitterLinkedIn
JS

JS Security Audit

Senior JavaScript security consultants with 10+ years of experience.