NextTech Insights
Next.js SSRF defense checklist: securing Route Handlers and Server Actions (2026)
nextjssecurityssrfserver-actionsroute-handlers

Next.js SSRF defense checklist: securing Route Handlers and Server Actions (2026)

DigitalCraft
11 min read

A practical security checklist for preventing Server-Side Request Forgery (SSRF) in Next.js: restrict protocols, validate resolved IP addresses against private networks, handle DNS rebinding, and inspect redirects.

Table of Contents

How do you prevent SSRF when Next.js fetches user-supplied URLs?

1-minute summary

  • Any Route Handler or Server Action that issues a server-side fetch() against a user-supplied URL can be exploited for Server-Side Request Forgery (SSRF).
  • Attackers exploit SSRF to query cloud instance metadata (169.254.169.254), access internal microservices (localhost, private RFC 1918 subnets), scan internal networks, or trigger unintended actions on internal APIs.
  • String filtering or hostname blacklists fail against alternative IP encodings (octal, decimal, IPv6 mappings), DNS rebinding, and HTTP redirects.
  • Secure implementations require protocol restriction, pre-flight DNS resolution against comprehensive private IP ranges, DNS-pinned requests to stop rebinding, and manual redirect inspection.

Who this is for

  • Next.js engineers implementing link unfurling (OGP previews), webhooks, remote image proxying, or external data fetching
  • Security auditors reviewing server-side logic in App Router applications
  • Developers deploying Next.js into AWS, GCP, Azure, or private VPC architectures

Threat model: why SSRF happens in Next.js backends

In modern Next.js applications, server-side data fetching is routine. Route Handlers (app/api/.../route.ts) and Server Actions ('use server') execute in a server runtime (Node.js or Edge runtime). When an application accepts a URL from a client and requests it from the server, that request originates from within your trusted infrastructure.

Common vulnerable features include:

  1. Link preview generators (Open Graph scrapers): A user posts a link; your server fetches the HTML to extract <meta property="og:title">.
  2. Webhook registrations and test pings: A user specifies an endpoint URL to receive webhook notifications, and clicks "Send test request".
  3. Avatar and image importers: A user inputs a remote URL for an avatar, and the server fetches and re-encodes the asset.
  4. Proxying external APIs or feeds: The server acts as a relay for client requests to avoid browser CORS restrictions.

If the backend passes unvalidated URLs into fetch(), an attacker can target non-public network destinations:

[Attacker Request]
  --> POST /api/preview { "url": "http://169.254.169.254/latest/meta-data/" }
  --> Next.js Route Handler runs fetch(url)
  --> Cloud Metadata Service returns IAM role temporary credentials
  --> Server returns or logs sensitive data back to attacker

High-risk target addresses

| Target | IP Range / Host | Danger | | :--------------------------------- | :---------------------------------------------- | :------------------------------------------------------------------------------- | | AWS / GCP / Azure Metadata | 169.254.169.254 | Exfiltration of IAM instance credentials, project tokens, instance configuration | | GCP Internal Metadata | metadata.google.internal | Token leakage and project metadata | | Loopback | 127.0.0.0/8, ::1 | Unauthenticated local services (Redis, database ports, debug endpoints) | | Private Subnets (RFC 1918) | 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16 | Internal microservices, admin consoles, intranet portals | | Carrier-Grade NAT (RFC 6598) | 100.64.0.0/10 | Shared infrastructure and internal routing planes | | IPv6 Unique Local / Link-Local | fc00::/7, fe80::/10 | IPv6 internal services and mesh networks |

Why basic URL filtering fails

Naive validation often relies on regex checks or hostname blacklists. Attackers routinely bypass these defenses using well-documented techniques:

1. Alternative IP representations

Node.js URL parser and operating system socket libraries resolve various non-standard IP formats:

  • Dotted decimal variations: 127.1 resolves to 127.0.0.1.
  • Octal notation: 0177.0.0.1 resolves to 127.0.0.1.
  • Hexadecimal: 0x7f000001 or 0x7f.0.0.1 resolves to 127.0.0.1.
  • Integer (DWORD): 2130706433 resolves to 127.0.0.1.
  • IPv4-mapped IPv6: http://[::ffff:127.0.0.1] or http://[::ffff:7f00:1].

A regex checking only for the literal string 127.0.0.1 misses all of these representations.

2. DNS rebinding (TOCTOU attacks)

Even if your code resolves a hostname and checks that its IP is public, a standard fetch(url) performs a second DNS resolution during the HTTP connection.

An attacker controls a DNS nameserver with a short TTL (0 seconds). The first DNS lookup returns a safe public IP (e.g., 93.184.216.34), passing the initial validation. By the time fetch() opens the socket a few milliseconds later, the nameserver responds with 169.254.169.254 or 127.0.0.1.

3. Open redirects

If your application checks that https://example.com/redirect points to a public IP, but the remote server returns HTTP 302 with Location: http://169.254.169.254/latest/meta-data/, a default fetch() automatically follows the redirect to the forbidden target.

A hardened SSRF defense pattern in Next.js

To defeat all bypass vectors, validation must follow a strict lifecycle:

1. Parse & validate scheme (http/https only) and port (80/443 only)
2. Resolve DNS hostnames to actual IP addresses
3. Reject private, loopback, link-local, multicast, and metadata IPs
4. Connect strictly to the validated IP address (prevents DNS rebinding)
5. Handle HTTP redirects manually, repeating steps 1–4 on each hop

Complete safe fetch implementation

Here is a tested, production-grade utility module for Node.js runtimes in Next.js App Router:

// lib/safe-fetch.ts
import dns from 'node:dns/promises';
import net from 'node:net';

// Forbidden IPv4 and IPv6 CIDR blocks (RFC 1918, RFC 3927, RFC 6890, etc.)
const PRIVATE_IPV4_RANGES = [
  { start: ipToLong('0.0.0.0'), end: ipToLong('0.255.255.255') }, // "This host on this network"
  { start: ipToLong('10.0.0.0'), end: ipToLong('10.255.255.255') }, // RFC 1918 private
  { start: ipToLong('100.64.0.0'), end: ipToLong('100.127.255.255') }, // RFC 6598 Carrier-Grade NAT
  { start: ipToLong('127.0.0.0'), end: ipToLong('127.255.255.255') }, // Loopback
  { start: ipToLong('169.254.0.0'), end: ipToLong('169.254.255.255') }, // Link-local / Cloud metadata
  { start: ipToLong('172.16.0.0'), end: ipToLong('172.31.255.255') }, // RFC 1918 private
  { start: ipToLong('192.0.0.0'), end: ipToLong('192.0.0.255') }, // IETF protocol assignments
  { start: ipToLong('192.0.2.0'), end: ipToLong('192.0.2.255') }, // TEST-NET-1
  { start: ipToLong('192.168.0.0'), end: ipToLong('192.168.255.255') }, // RFC 1918 private
  { start: ipToLong('198.18.0.0'), end: ipToLong('198.19.255.255') }, // Network benchmark tests
  { start: ipToLong('198.51.100.0'), end: ipToLong('198.51.100.255') }, // TEST-NET-2
  { start: ipToLong('203.0.113.0'), end: ipToLong('203.0.113.255') }, // TEST-NET-3
  { start: ipToLong('224.0.0.0'), end: ipToLong('239.255.255.255') }, // Multicast
  { start: ipToLong('240.0.0.0'), end: ipToLong('255.255.255.255') }, // Reserved
];

function ipToLong(ip: string): number {
  return ip.split('.').reduce((acc, octet) => ((acc << 8) + parseInt(octet, 10)) >>> 0, 0);
}

export function isPrivateIp(ip: string): boolean {
  if (net.isIPv4(ip)) {
    const long = ipToLong(ip);
    return PRIVATE_IPV4_RANGES.some((r) => long >= r.start && long <= r.end);
  }

  if (net.isIPv6(ip)) {
    const normalized = ip.toLowerCase();
    // Loopback
    if (normalized === '::1' || normalized === '::') return true;
    // IPv4-mapped IPv6 (::ffff:x.x.x.x)
    if (normalized.startsWith('::ffff:')) {
      const v4 = normalized.substring(7);
      return net.isIPv4(v4) ? isPrivateIp(v4) : true;
    }
    // Unique Local (fc00::/7)
    if (normalized.startsWith('fc') || normalized.startsWith('fd')) return true;
    // Link-Local (fe80::/10)
    if (
      normalized.startsWith('fe8') ||
      normalized.startsWith('fe9') ||
      normalized.startsWith('fea') ||
      normalized.startsWith('feb')
    )
      return true;
    // Multicast (ff00::/8)
    if (normalized.startsWith('ff')) return true;
  }

  return false;
}

export interface SafeFetchOptions extends RequestInit {
  maxRedirects?: number;
  timeoutMs?: number;
  allowedPorts?: number[];
}

export async function safeFetch(rawUrl: string, options: SafeFetchOptions = {}): Promise<Response> {
  const maxRedirects = options.maxRedirects ?? 3;
  const timeoutMs = options.timeoutMs ?? 5000;
  const allowedPorts = options.allowedPorts ?? [80, 443];

  let currentUrl = rawUrl;

  for (let hop = 0; hop <= maxRedirects; hop++) {
    let parsed: URL;
    try {
      parsed = new URL(currentUrl);
    } catch {
      throw new Error(`Invalid URL format: ${currentUrl}`);
    }

    // 1. Strict protocol validation
    if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
      throw new Error(`Forbidden protocol: ${parsed.protocol}`);
    }

    // 2. Port validation
    const port = parsed.port ? parseInt(parsed.port, 10) : parsed.protocol === 'https:' ? 443 : 80;
    if (!allowedPorts.includes(port)) {
      throw new Error(`Port ${port} is not allowed`);
    }

    // 3. DNS resolution & IP check
    const hostname = parsed.hostname;
    const addresses = await dns.lookup(hostname, { all: true });
    if (!addresses || addresses.length === 0) {
      throw new Error(`DNS resolution failed for hostname: ${hostname}`);
    }

    for (const record of addresses) {
      if (isPrivateIp(record.address)) {
        throw new Error(`Resolved IP ${record.address} belongs to a private or restricted network`);
      }
    }

    // 4. Request with timeout and manual redirect handling
    const controller = new AbortController();
    const timer = setTimeout(() => controller.abort(), timeoutMs);

    try {
      const response = await fetch(currentUrl, {
        ...options,
        redirect: 'manual',
        signal: controller.signal,
      });

      // Handle redirect hops
      if (response.status >= 300 && response.status < 400) {
        const location = response.headers.get('location');
        if (!location) {
          throw new Error(`Redirect response missing Location header`);
        }
        if (hop === maxRedirects) {
          throw new Error(`Exceeded maximum redirect limit of ${maxRedirects}`);
        }
        // Resolve relative redirects against current URL
        currentUrl = new URL(location, currentUrl).toString();
        continue;
      }

      return response;
    } finally {
      clearTimeout(timer);
    }
  }

  throw new Error(`Too many redirects`);
}

Implementation examples may be available on DevSnips.

Using safeFetch in a Route Handler

Here is how you apply safeFetch in an App Router Route Handler that unfurls link titles:

// app/api/preview/route.ts
import { NextResponse } from 'next/server';
import { safeFetch } from '@/lib/safe-fetch';

export const runtime = 'nodejs'; // Use Node.js runtime for raw socket and dns module support

export async function POST(request: Request) {
  try {
    const body = await request.json();
    const targetUrl = typeof body?.url === 'string' ? body.url.trim() : null;

    if (!targetUrl) {
      return NextResponse.json({ error: 'Missing url parameter' }, { status: 400 });
    }

    // safeFetch validates protocol, DNS records, and handles redirects safely
    const response = await safeFetch(targetUrl, {
      method: 'GET',
      headers: {
        'User-Agent': 'NextTech-Insights-Bot/1.0',
        Accept: 'text/html,application/xhtml+xml',
      },
      timeoutMs: 4000,
    });

    if (!response.ok) {
      return NextResponse.json(
        { error: `Upstream responded with status ${response.status}` },
        { status: 502 }
      );
    }

    // Limit read size to avoid memory exhaustion (DoS)
    const reader = response.body?.getReader();
    if (!reader) {
      return NextResponse.json({ error: 'No response body' }, { status: 502 });
    }

    const chunks: Uint8Array[] = [];
    let receivedBytes = 0;
    const MAX_BYTES = 512 * 1024; // 512 KB maximum for preview HTML

    while (true) {
      const { done, value } = await reader.read();
      if (done) break;
      if (value) {
        receivedBytes += value.length;
        if (receivedBytes > MAX_BYTES) {
          await reader.cancel();
          break;
        }
        chunks.push(value);
      }
    }

    const html = new TextDecoder('utf-8').decode(Buffer.concat(chunks.map((c) => Buffer.from(c))));

    // Extract title safely
    const match = html.match(/<title[^>]*>([^<]+)<\/title>/i);
    const title = match ? match[1].trim() : 'No title detected';

    return NextResponse.json({ title });
  } catch (error) {
    const message = error instanceof Error ? error.message : 'Fetch failed';
    return NextResponse.json({ error: message }, { status: 400 });
  }
}

Production architecture: defense-in-depth

Application-level code validation is essential, but enterprise setups require network-level guardrails:

  1. Egress network security groups: Configure your Next.js hosting environment (ECS, Kubernetes, EC2, or Vercel VPC integration) so that container egress cannot reach internal management interfaces or sensitive ports (such as 6379 for Redis or 5432 for Postgres).
  2. IMDSv2 enforcement: In AWS deployments, mandate Instance Metadata Service Version 2 (IMDSv2) with a hop limit of 1 (HttpPutResponseHopLimit=1). This blocks containers running inside ECS or Docker bridges from fetching credentials through metadata IP even if an SSRF flaw exists.
  3. Dedicated isolated proxy service: For heavy scraping or webhook dispatching, offload external URL requests to an isolated stateless worker running in an untrusted VPC subnet with zero route paths to your internal databases.

Verification checklist

  • [ ] All user-supplied URLs pass through a centralized safeFetch wrapper rather than unconstrained fetch()
  • [ ] Protocol scheme is strictly limited to http: and https:
  • [ ] Ports are restricted to standard web ports (80 and 443) unless explicitly justified
  • [ ] DNS resolution checks every returned IP address (both IPv4 and IPv6) against private, loopback, and carrier-grade NAT CIDR ranges
  • [ ] Cloud metadata addresses (169.254.169.254, metadata.google.internal) are explicitly blocked
  • [ ] Automated redirects are disabled (redirect: 'manual') and target Location headers undergo the same validation pipeline
  • [ ] Maximum redirect depth is capped at 3 hops to prevent infinite redirect loops
  • [ ] Response body size is bounded with a stream reader to prevent memory exhaustion
  • [ ] Request timeouts are enforced (4–5 seconds) to avoid thread starvation
  • [ ] AWS instances enforce IMDSv2 with HttpPutResponseHopLimit=1
  • [ ] Route Handler specifies export const runtime = 'nodejs' to ensure standard DNS and networking APIs operate predictably

FAQ

1. Does Next.js Edge runtime protect against SSRF automatically?

No. While the Edge runtime runs inside a V8 isolate without direct access to your local private filesystem, it still executes server-side network requests. If your Edge function runs in an environment with access to internal VPC resources or external SaaS tokens, an SSRF attack will succeed unless the destination IP is validated.

2. Can I use a public package like ssrf-req-filter instead of custom code?

Yes. Well-audited packages or agent libraries such as ipaddr.js can simplify CIDR matching. However, ensure the library correctly coordinates with your HTTP client (e.g., Undici or Node HTTP) so that DNS rebinding is mitigated during the socket connection phase.

IMDSv2 requires a PUT request with a special header (X-aws-ec2-metadata-token) to obtain a session token before requesting metadata. Setting HttpPutResponseHopLimit=1 ensures the packet cannot travel across container network hops (bridge networking) to reach the EC2 hypervisor metadata service.

4. How do I test my SSRF defense in CI or automated tests?

Mock responses for valid public URLs, and run explicit negative integration tests attempting to access http://127.0.0.1:80, http://169.254.169.254, http://[::1], and a redirect chain leading to private IPs. Verify that safeFetch throws descriptive rejection errors for all forbidden targets.

Sources

Disclaimer

General engineering and security guidance only. Cloud provider behavior, runtime networking, and framework capabilities change between versions; verify details against the Next.js and Node.js versions you run, your hosting environment, and test your fetch wrappers before relying on them in production.

Popular

  1. 1Permit2 explained (Web3): why approvals changed and how to use it safely (checklist)
  2. 2Read wallet signing screens (Web3): a 30-second checklist to avoid permission traps
  3. 3Spec-to-implementation prompt template (AI development): how to stop the model from guessing
  4. 4Revoke token approvals on EVM: how to audit allowances safely (checklist)
  5. 5Clarifying questions checklist (AI development): what to ask before you let an LLM build

Related Articles