Next.js Security Scan
Performs a defensive security review of a Next.js App Router project (Next.js 13+). Reports findings using the shared scoring schema.
Scope
Detects only — does not modify code. Targets:
app/** — Server Components, Client Components, server actions, route handlers
middleware.ts / middleware.js
next.config.{js,ts,mjs}
.env*
Out of scope: dependency CVEs (use npm audit / Snyk), runtime DAST.
Procedure
- Enumerate target files via
Glob/Grep. Read each one once with Read.
- Apply each rule in the table below. For every match, record a finding with severity, location, evidence, and fix.
- Emit the final report in the schema from
SCORING.md.
Rules
| ID |
Severity |
Detection |
Fix |
| NEXTJS-ENV-001 |
critical |
process.env.NEXT_PUBLIC_* referencing a secret-shaped name (*_KEY, *_SECRET, *_TOKEN, *_PASSWORD, *_DSN) |
Remove NEXT_PUBLIC_ prefix; access only in Server Components / route handlers |
| NEXTJS-ENV-002 |
high |
Secret-shaped env var read inside a file containing "use client" |
Move read to a Server Component or route handler; pass derived non-secret value via props |
| NEXTJS-SA-001 |
high |
Exported async function in a "use server" file with no auth check (no call to auth(), getServerSession, cookies()-based check, or equivalent) before mutation |
Add session check at the top of the action; return early on unauthenticated |
| NEXTJS-SA-002 |
high |
Server action accepts unvalidated FormData/object and passes fields directly to DB / fetch |
Validate with zod/valibot schema before use |
| NEXTJS-MW-001 |
high |
middleware.ts has a matcher that excludes auth-sensitive routes (e.g., /api/admin, /dashboard) but no per-route check exists |
Tighten matcher or add per-route guards |
| NEXTJS-MW-002 |
medium |
Middleware reads JWT but does not verify signature (jwt.decode instead of jwt.verify / jose.jwtVerify) |
Use jose.jwtVerify with explicit issuer/audience |
| NEXTJS-RH-001 |
high |
route.ts returns response with Access-Control-Allow-Origin: * together with Access-Control-Allow-Credentials: true |
Pin origin to an allowlist; never combine wildcard with credentials |
| NEXTJS-RH-002 |
medium |
route.ts POST/PUT/DELETE handler reads request.json() without schema validation before persisting |
Validate with zod before use |
| NEXTJS-RH-003 |
medium |
route.ts does not set runtime and uses Node-only crypto/secrets (risk of accidental Edge migration losing protections) |
Add export const runtime = 'nodejs' explicitly |
| NEXTJS-REVAL-001 |
medium |
revalidatePath / revalidateTag called with a path/tag built from request input |
Whitelist allowed paths; never interpolate user input |
| NEXTJS-IMG-001 |
medium |
next.config images.remotePatterns uses ** host or omits pathname |
Pin host and path prefix |
| NEXTJS-CSP-001 |
medium |
No Content-Security-Policy set in middleware.ts / next.config headers |
Add CSP with nonce-based script-src |
| NEXTJS-DSI-001 |
high |
dangerouslySetInnerHTML with value not provably static (template literal, prop, state, fetched data) |
Render as text, or sanitize with DOMPurify (server-side) |
| NEXTJS-LOG-001 |
medium |
console.log of request.headers, cookies(), session, or full request.body |
Redact before logging; log IDs not payloads |
Wrong vs. right
NEXTJS-ENV-001 (env leak)
// ❌ Exposes the API key to every browser bundle
const key = process.env.NEXT_PUBLIC_ANTHROPIC_API_KEY;
// ✅ Server-only access
// app/api/chat/route.ts
export async function POST(req: Request) {
const key = process.env.ANTHROPIC_API_KEY;
// ...
}
NEXTJS-SA-001 (unauth server action)
// ❌ Anyone who can hit the form can call this
"use server";
export async function deleteUser(id: string) {
await db.user.delete({ where: { id } });
}
// ✅ Session-checked, schema-validated
"use server";
import { z } from "zod";
import { auth } from "@/auth";
const schema = z.object({ id: z.string().uuid() });
export async function deleteUser(input: unknown) {
const session = await auth();
if (!session?.user || session.user.role !== "admin") {
throw new Error("Unauthorized");
}
const { id } = schema.parse(input);
await db.user.delete({ where: { id } });
}
NEXTJS-RH-001 (CORS + credentials)
// ❌ Browsers will reject this in modern versions, but older clients won't
return new Response(data, {
headers: {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Credentials": "true",
},
});
// ✅ Origin allowlist
const ALLOWED = new Set(["https://app.example.com"]);
const origin = req.headers.get("origin") ?? "";
const allow = ALLOWED.has(origin) ? origin : "";
return new Response(data, {
headers: {
"Access-Control-Allow-Origin": allow,
"Access-Control-Allow-Credentials": "true",
"Vary": "Origin",
},
});
References
1---2name: nextjs-security-scan3description: Defensive security scan for Next.js App Router projects. Detects environment-variable leakage to the browser bundle, unauthenticated server actions, missing middleware auth, unsafe CORS on route handlers, and trust-boundary violations in revalidatePath/revalidateTag. Invoke when the user asks to "review", "audit", or "scan" a Next.js codebase, or when editing files under app/ or middleware.ts.4---56# Next.js Security Scan78Performs a defensive security review of a Next.js App Router project (Next.js 13+). Reports findings using the [shared scoring schema](../../../SCORING.md).910## Scope1112Detects only — does not modify code. Targets:1314- `app/**` — Server Components, Client Components, server actions, route handlers15- `middleware.ts` / `middleware.js`16- `next.config.{js,ts,mjs}`17- `.env*`1819Out of scope: dependency CVEs (use `npm audit` / Snyk), runtime DAST.2021## Procedure22231. Enumerate target files via `Glob`/`Grep`. Read each one once with `Read`.242. Apply each rule in the table below. For every match, record a finding with severity, location, evidence, and fix.253. Emit the final report in the schema from `SCORING.md`.2627## Rules2829| ID | Severity | Detection | Fix |30|----|----------|-----------|-----|31| NEXTJS-ENV-001 | critical | `process.env.NEXT_PUBLIC_*` referencing a secret-shaped name (`*_KEY`, `*_SECRET`, `*_TOKEN`, `*_PASSWORD`, `*_DSN`) | Remove `NEXT_PUBLIC_` prefix; access only in Server Components / route handlers |32| NEXTJS-ENV-002 | high | Secret-shaped env var read inside a file containing `"use client"` | Move read to a Server Component or route handler; pass derived non-secret value via props |33| NEXTJS-SA-001 | high | Exported `async function` in a `"use server"` file with no auth check (no call to `auth()`, `getServerSession`, `cookies()`-based check, or equivalent) before mutation | Add session check at the top of the action; return early on unauthenticated |34| NEXTJS-SA-002 | high | Server action accepts unvalidated `FormData`/object and passes fields directly to DB / fetch | Validate with `zod`/`valibot` schema before use |35| NEXTJS-MW-001 | high | `middleware.ts` has a `matcher` that excludes auth-sensitive routes (e.g., `/api/admin`, `/dashboard`) but no per-route check exists | Tighten `matcher` or add per-route guards |36| NEXTJS-MW-002 | medium | Middleware reads JWT but does not verify signature (`jwt.decode` instead of `jwt.verify` / `jose.jwtVerify`) | Use `jose.jwtVerify` with explicit `issuer`/`audience` |37| NEXTJS-RH-001 | high | `route.ts` returns response with `Access-Control-Allow-Origin: *` together with `Access-Control-Allow-Credentials: true` | Pin origin to an allowlist; never combine wildcard with credentials |38| NEXTJS-RH-002 | medium | `route.ts` `POST`/`PUT`/`DELETE` handler reads `request.json()` without schema validation before persisting | Validate with `zod` before use |39| NEXTJS-RH-003 | medium | `route.ts` does not set `runtime` and uses Node-only crypto/secrets (risk of accidental Edge migration losing protections) | Add `export const runtime = 'nodejs'` explicitly |40| NEXTJS-REVAL-001 | medium | `revalidatePath` / `revalidateTag` called with a path/tag built from request input | Whitelist allowed paths; never interpolate user input |41| NEXTJS-IMG-001 | medium | `next.config` `images.remotePatterns` uses `**` host or omits `pathname` | Pin host and path prefix |42| NEXTJS-CSP-001 | medium | No `Content-Security-Policy` set in `middleware.ts` / `next.config` headers | Add CSP with nonce-based script-src |43| NEXTJS-DSI-001 | high | `dangerouslySetInnerHTML` with value not provably static (template literal, prop, state, fetched data) | Render as text, or sanitize with DOMPurify (server-side) |44| NEXTJS-LOG-001 | medium | `console.log` of `request.headers`, `cookies()`, `session`, or full `request.body` | Redact before logging; log IDs not payloads |4546## Wrong vs. right4748### NEXTJS-ENV-001 (env leak)4950```ts51// ❌ Exposes the API key to every browser bundle52const key = process.env.NEXT_PUBLIC_ANTHROPIC_API_KEY;53```5455```ts56// ✅ Server-only access57// app/api/chat/route.ts58export async function POST(req: Request) {59 const key = process.env.ANTHROPIC_API_KEY;60 // ...61}62```6364### NEXTJS-SA-001 (unauth server action)6566```ts67// ❌ Anyone who can hit the form can call this68"use server";69export async function deleteUser(id: string) {70 await db.user.delete({ where: { id } });71}72```7374```ts75// ✅ Session-checked, schema-validated76"use server";77import { z } from "zod";78import { auth } from "@/auth";7980const schema = z.object({ id: z.string().uuid() });8182export async function deleteUser(input: unknown) {83 const session = await auth();84 if (!session?.user || session.user.role !== "admin") {85 throw new Error("Unauthorized");86 }87 const { id } = schema.parse(input);88 await db.user.delete({ where: { id } });89}90```9192### NEXTJS-RH-001 (CORS + credentials)9394```ts95// ❌ Browsers will reject this in modern versions, but older clients won't96return new Response(data, {97 headers: {98 "Access-Control-Allow-Origin": "*",99 "Access-Control-Allow-Credentials": "true",100 },101});102```103104```ts105// ✅ Origin allowlist106const ALLOWED = new Set(["https://app.example.com"]);107const origin = req.headers.get("origin") ?? "";108const allow = ALLOWED.has(origin) ? origin : "";109return new Response(data, {110 headers: {111 "Access-Control-Allow-Origin": allow,112 "Access-Control-Allow-Credentials": "true",113 "Vary": "Origin",114 },115});116```117118## References119120- Next.js Server Actions: https://nextjs.org/docs/app/api-reference/functions/server-actions121- Next.js Middleware: https://nextjs.org/docs/app/building-your-application/routing/middleware122- Next.js Environment Variables: https://nextjs.org/docs/app/building-your-application/configuring/environment-variables123- OWASP CORS: https://owasp.org/www-community/attacks/CORS_OriginHeaderScrutiny