Route handlers are public endpoints, not private helpers
In the Next.js App Router, a route.ts file creates an HTTP endpoint that can receive external traffic. That means it must be treated as an exposed security boundary, even when the endpoint exists only to support a form, tool, or dashboard interaction.
The common mistake is trusting the client because the endpoint was written near the frontend. Attackers do not care where the file lives. They care whether the URL accepts arbitrary JSON, performs expensive work, or exposes data without enough authorization.
A secure route handler should start with boring controls: method design, payload limits, schema validation, authentication, authorization, rate limits, and predictable error responses.
Related context
Continue with the wider Cresnex research library
This article is part of a broader Cresnex library on cybersecurity, AI risk, online fraud, and India-specific digital trust. Use the links below to continue reading related explainers and research briefs.
Validate runtime input with Zod before touching business logic
TypeScript protects developers at build time, but route handlers receive data at runtime from untrusted clients. A schema validator such as Zod gives the server a clear contract for what it will accept.
Use safeParse so invalid requests return controlled errors instead of triggering unexpected exceptions. Keep schemas narrow, set maximum lengths, and avoid passing raw request objects deep into internal services.
import { NextResponse } from "next/server";
import { z } from "zod";
const scanRequestSchema = z.object({
code: z.string().min(1).max(100_000),
language: z.enum(["javascript", "typescript", "python", "go"]),
options: z
.object({
enableDeepScan: z.boolean().default(false),
})
.default({ enableDeepScan: false }),
});
export async function POST(request: Request) {
let body: unknown;
try {
body = await request.json();
} catch {
return NextResponse.json({ error: "Malformed JSON payload" }, { status: 400 });
}
const parsed = scanRequestSchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json(
{ error: "Invalid request body", details: parsed.error.flatten() },
{ status: 400 },
);
}
const { code, language, options } = parsed.data;
return NextResponse.json({
accepted: true,
language,
deepScan: options.enableDeepScan,
bytes: new TextEncoder().encode(code).length,
});
}Use shared rate limiting for serverless deployments
In-memory maps can be useful for local development, but they are not reliable protection for serverless production. Functions can scale horizontally, cold start, or run in different regions, which means each instance has its own memory.
For real deployments, use shared storage such as Redis from a managed provider. The important pattern is to key by a stable signal, increment with expiry, and return a 429 response before expensive work begins.
type RateLimitResult = {
allowed: boolean;
remaining: number;
resetAt: number;
};
export async function checkRateLimit({
key,
limit = 60,
windowSeconds = 60,
redis,
}: {
key: string;
limit?: number;
windowSeconds?: number;
redis: {
incr: (key: string) => Promise<number>;
expire: (key: string, seconds: number) => Promise<unknown>;
ttl: (key: string) => Promise<number>;
};
}): Promise<RateLimitResult> {
const bucket = `rate:${key}`;
const count = await redis.incr(bucket);
if (count === 1) {
await redis.expire(bucket, windowSeconds);
}
const ttl = Math.max(await redis.ttl(bucket), 0);
return {
allowed: count <= limit,
remaining: Math.max(limit - count, 0),
resetAt: Date.now() + ttl * 1000,
};
}Harden responses and authorization paths
Good validation is not enough if the endpoint leaks internal error messages or allows a user to request data outside their permission scope. Return narrow errors, avoid echoing sensitive values, and authorize against server-side identity rather than client-supplied IDs alone.
For code-scanning, AI, upload, or report-generation endpoints, also consider request body size limits, timeouts, structured logs, abuse alerts, and post-response background work where appropriate.
The practical goal is to make abuse expensive before your route handler reaches the expensive part of the workflow.
