Skip to Content
Code ReviewsNext.js Apps Security Hardening — May 2026

Next.js Apps Security Hardening — May 2026

Date: 2026-05-07
Scope: apps/dm, apps/manage, apps/dashboard, apps/client-api, apps/knowledgebase, packages/middleware
Reviewer: Claude (automated security review)
Status: All findings resolved (one low-priority item intentionally deferred)


Summary

Full security review of all Next.js portals, the public-facing client-api, the knowledgebase app, and the shared middleware package. 14 findings resolved across High/Medium/Low severity categories.


Findings and Resolutions

H-1 · DM proxy routes — unsanitized activeTenantId in URL interpolation

Files: apps/dm/src/app/api/keyword-groups/[id]/approve/route.ts, context/revise/route.ts, strategy/revise/route.ts

activeTenantId was read from a cookie and interpolated directly into a fetch URL with no validation. A malformed value could produce a misrouted or malformed API request.

Fix: UUID regex guard added before URL construction in all three routes:

const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; if (!UUID.test(activeTenantId)) return NextResponse.json({ error: "Invalid tenant" }, { status: 400 });

H-2 · DM approve/reject routes — no server-side tenant check

Files: 8 routes across blog, social, newsletters, landing-pages (approve + reject each)

Routes read dm_active_tenant only on the client side. The server-side route handlers had no check — any authenticated DM user could approve/reject content for any tenant.

Fix: cookies() check added at the top of every handler; returns 403 if dm_active_tenant is absent.


H-3 · DM reject routes — arbitrary body forwarded to API

Files: All 4 DM reject routes (blog, social, newsletters, landing-pages)

The raw parsed request body was forwarded wholesale to the backend API, allowing injection of arbitrary fields.

Fix: Reject routes now forward only { reason }, truncated to 1000 characters:

{ reason: typeof rawBody.reason === "string" ? rawBody.reason.slice(0, 1000) : undefined }

H-4 · DM approve routes — parsed body forwarded unnecessarily

Files: All 4 DM approve routes

Approve routes parsed and forwarded the request body even though the backend doesn’t require a body for approval.

Fix: Approve routes now send a hardcoded empty body ("{}").


M-1 · Manage audit-logs proxy — raw querystring forwarded

File: apps/manage/src/app/api/admin/audit-logs/route.ts

All query params from the incoming request were forwarded to the API without filtering, allowing unintended params to reach the backend.

Fix: Explicit allowlist applied:

const ALLOWED = ["page", "limit", "actorId", "tenantId", "action", "resourceType", "from", "to"];

M-2 · DM auto-select route — no UUID validation on tenant ID, open redirect on next

File: apps/dm/src/app/api/tenant/auto-select/route.ts

id param was written directly to a cookie without UUID validation. next param could contain an arbitrary value, enabling an open redirect.

Fix: UUID regex applied to id; next is sanitized:

const safeNext = next.startsWith("/") ? next : "/overview";

M-3 · Manage server actions — error responses not parsed correctly

Files: apps/manage/src/app/actions/important-days.ts, design-defaults.ts

await res.text() was used to read error responses, which would surface raw JSON strings rather than the message field.

Fix: Replaced with .json() parse + message extraction with a safe fallback:

await res.json().then((d: { error?: { message?: string } }) => d?.error?.message ?? "Request failed").catch(() => "Request failed")

M-4 · Missing serverActions.bodySizeLimit in next.config.ts

Files: apps/dm/next.config.ts, apps/manage/next.config.ts, apps/dashboard/next.config.ts

No body size limit was set for server actions, leaving the default (1MB) in place.

Fix: Added to all three apps — 256kb for dm/manage, 512kb for dashboard (which handles file uploads).


M-5 · Middleware — JWT_SECRET not enforced at startup in production

File: packages/middleware/src/index.ts

If JWT_SECRET was unset in production, the middleware would silently fall back to "dev-secret", making all tokens trivially forgeable.

Fix: Fail-fast check added at module load time:

const IS_PROD = process.env.NODE_ENV === "production"; if (IS_PROD && !process.env.JWT_SECRET) { throw new Error("FATAL: JWT_SECRET must be set in production"); }

M-6 · Middleware — rotated refresh token discarded on silent refresh

File: packages/middleware/src/index.ts

When an expired access token was silently refreshed via the /auth/v1/refresh endpoint, the API was returning a new refresh token (as part of rotation), but the middleware only stored the new access token cookie — the rotated refresh token was discarded. On the next refresh cycle the old (still-valid) refresh token would be reused, defeating rotation.

Fix: refreshAccessToken return type updated to { accessToken, refreshToken } | null. Caller now sets both cookies:

response.cookies.set(refreshTokenCookie, tokens.refreshToken, { httpOnly: true, secure: IS_SECURE, maxAge: 7 * 24 * 60 * 60, sameSite: "lax", path: "/", });

L-1 · client-api — no request ID or Cache-Control headers

File: apps/client-api/src/app.ts

The Fastify instance had no genReqId config and no onSend hook, so requests had no correlation ID and responses had no cache headers.

Fix: Added genReqId: () => randomUUID(), requestIdHeader: "x-request-id", requestIdLogLabel: "reqId", and an onSend hook that echoes X-Request-Id and sets Cache-Control: private, no-store.


L-2 · client-api — no UUID validation on landing page ID param

File: apps/client-api/src/routers/landing-pages.ts

The id path parameter was passed directly to a Prisma query without format validation, so non-UUID strings would produce a Prisma runtime error rather than a clean 400.

Fix: Zod UUID validation added before the DB call:

const ParamSchema = z.string().uuid(); if (!ParamSchema.safeParse(id).success) { return reply.status(400).send({ error: { code: "INVALID_ID", message: "Invalid landing page ID" } }); }

L-3 · knowledgebase — Mermaid securityLevel: "loose" allows arbitrary script execution

File: apps/knowledgebase/src/components/MermaidDiagram.tsx

loose mode allows Mermaid diagram definitions to execute arbitrary JavaScript via click handlers and similar constructs.

Fix: Changed to securityLevel: "strict".


L-4 · knowledgebase — no robots.txt or noindex directive

Files: apps/knowledgebase/public/robots.txt (new), apps/knowledgebase/src/app/layout.tsx

Internal knowledgebase app was indexable by search engines.

Fix: robots.txt with Disallow: / created; robots: { index: false, follow: false } added to layout metadata.


L-5 · client-api issue 5 — deferred

Intentionally not addressed in this session.


Patterns to Follow for New Next.js Code

Proxy routes — never forward raw body or querystring:

  • Allowlist specific query params
  • Extract and re-construct only the fields you intend to forward in the body

Cookie-gated DM routes — always check dm_active_tenant server-side:

const activeTenantId = (await cookies()).get("dm_active_tenant")?.value; if (!activeTenantId) return NextResponse.json({ error: "No active tenant" }, { status: 403 });

UUID from cookie/param — always validate before use:

const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; if (!UUID.test(value)) return NextResponse.json({ error: "Invalid ID" }, { status: 400 });

Redirect targets from user input — always sanitize:

const safeNext = next.startsWith("/") ? next : "/fallback";

© 2026 Leadmetrics — Internal use only