Next.js SSRF defense checklist: securing Route Handlers and Server Actions (2026)
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
- Who this is for
- Threat model: why SSRF happens in Next.js backends
- High-risk target addresses
- Why basic URL filtering fails
- 1. Alternative IP representations
- 2. DNS rebinding (TOCTOU attacks)
- 3. Open redirects
- A hardened SSRF defense pattern in Next.js
- Complete safe fetch implementation
- Using safeFetch in a Route Handler
- Production architecture: defense-in-depth
- Verification checklist
- FAQ
- 1. Does Next.js Edge runtime protect against SSRF automatically?
- 2. Can I use a public package like ssrf-req-filter instead of custom code?
- 3. Why is IMDSv2 with hop limit 1 recommended for AWS?
- 4. How do I test my SSRF defense in CI or automated tests?
- Sources
- Internal links
- Disclaimer
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:
- Link preview generators (Open Graph scrapers): A user posts a link; your server fetches the HTML to extract
<meta property="og:title">. - Webhook registrations and test pings: A user specifies an endpoint URL to receive webhook notifications, and clicks "Send test request".
- Avatar and image importers: A user inputs a remote URL for an avatar, and the server fetches and re-encodes the asset.
- 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.1resolves to127.0.0.1. - Octal notation:
0177.0.0.1resolves to127.0.0.1. - Hexadecimal:
0x7f000001or0x7f.0.0.1resolves to127.0.0.1. - Integer (DWORD):
2130706433resolves to127.0.0.1. - IPv4-mapped IPv6:
http://[::ffff:127.0.0.1]orhttp://[::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:
- 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
6379for Redis or5432for Postgres). - 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. - 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
safeFetchwrapper rather than unconstrainedfetch() - [ ] Protocol scheme is strictly limited to
http:andhttps: - [ ] 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 targetLocationheaders 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.
3. Why is IMDSv2 with hop limit 1 recommended for AWS?
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
- Server-Side Request Forgery Prevention Cheat Sheet (OWASP)
- Route Handlers (Next.js Docs)
- Server Actions and Mutations (Next.js Docs)
- DNS module documentation (Node.js Docs)
- RFC 6890: Special-Purpose IP Address Registries (IETF)
- CWE-918: Server-Side Request Forgery (SSRF) (MITRE)
Internal links
- Parent hub: Next.js security
- Related:
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
- 1Permit2 explained (Web3): why approvals changed and how to use it safely (checklist)
- 2Read wallet signing screens (Web3): a 30-second checklist to avoid permission traps
- 3Spec-to-implementation prompt template (AI development): how to stop the model from guessing
- 4Revoke token approvals on EVM: how to audit allowances safely (checklist)
- 5Clarifying questions checklist (AI development): what to ask before you let an LLM build