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";