Quick Answer
Most AI-generated API routes pass req.body directly to database queries, email functions, and business logic without checking that the data is the right type, shape, or range. This enables injection attacks, data corruption, and unexpected application behavior. Adding Zod or Joi schema validation at the entry point of every route takes 10-15 lines and closes the entire class of input-based vulnerabilities.
Why Input Validation Is the Most Skipped Security Step
When AI tools generate an API route, they focus on the happy path: the user sends the right data, the query runs, the response goes back. Validation is an extra step that doesn't change whether the demo works - so it gets skipped. The result is routes that accept anything: empty strings where IDs are expected, negative numbers where ages are expected, and SQL fragments where names are expected.
OWASP's Injection category - which input validation directly prevents - sits at #3 in the OWASP Top 10. The broader "Insecure Design" category (#4) explicitly calls out missing business logic validation. Veracode's 2023 report found that input validation flaws appear in 22% of all scanned applications, making it one of the most prevalent weakness categories.
The Cloud Security Alliance's 2024 AI code security analysis found that fewer than 15% of AI-generated API endpoints included any form of input schema validation. The remaining 85% passed request data directly to downstream operations. In a Next.js app with 20 API routes, that's potentially 17 unvalidated entry points into your database and business logic.
What Unvalidated API Routes Look Like
Here's a realistic example of what Bolt, Cursor, or v0 generate for a "create user" endpoint:
// ❌ BAD - No input validation, direct use of req.body
// Next.js App Router: app/api/users/route.ts
export async function POST(request: Request) {
const body = await request.json();
// No checks: body.email could be undefined, an object, or "'; DROP TABLE users; --"
const user = await prisma.user.create({
data: {
email: body.email,
name: body.name,
age: body.age,
role: body.role, // Attacker can set role: "admin"
}
});
return Response.json(user);
}
// ❌ BAD - Express route with no validation
app.post('/api/orders', async (req, res) => {
const { userId, productId, quantity, price } = req.body;
// price comes from client - never trust client-side prices!
const order = await db.orders.create({
userId,
productId,
quantity,
totalPrice: price * quantity
});
res.json(order);
});
The first example has a critical business logic flaw: an attacker can set role: "admin" in the request body to escalate their own privileges. The second lets attackers set their own price for orders - a real attack seen against e-commerce apps built with AI tools. Both pass without any validation or error.
Adding Zod Validation: The Modern Standard
Zod is the TypeScript-first schema validation library that has become the standard for Next.js and Supabase apps. It provides type inference, composable schemas, and readable error messages with minimal boilerplate.
// ✅ GOOD - Zod validation in Next.js App Router
import { z } from 'zod';
const CreateUserSchema = z.object({
email: z.string().email('Must be a valid email'),
name: z.string().min(1, 'Name is required').max(100),
age: z.number().int().min(13).max(120).optional(),
// Never allow role to be set by the client
});
export async function POST(request: Request) {
const body = await request.json();
const result = CreateUserSchema.safeParse(body);
if (!result.success) {
return Response.json(
{ errors: result.error.flatten().fieldErrors },
{ status: 400 }
);
}
// result.data is fully typed and validated
const user = await prisma.user.create({
data: {
...result.data,
role: 'user', // Always set server-side, never from input
}
});
return Response.json(user);
}
// ✅ GOOD - Express route with Joi validation
import Joi from 'joi';
const orderSchema = Joi.object({
userId: Joi.string().uuid().required(),
productId: Joi.string().uuid().required(),
quantity: Joi.number().integer().min(1).max(100).required(),
// Price is never accepted from client - fetched from DB
});
app.post('/api/orders', async (req, res) => {
const { error, value } = orderSchema.validate(req.body);
if (error) {
return res.status(400).json({ error: error.details[0].message });
}
// Fetch price from database, never trust client
const product = await db.products.findById(value.productId);
const order = await db.orders.create({
...value,
totalPrice: product.price * value.quantity
});
res.json(order);
});
What to Validate and What Not to Trust
| Field type | Validation needed | Never trust from client |
|---|---|---|
| Email addresses | Format, length, domain | - |
| Numeric IDs | Type (int), range, UUID format | - |
| Prices / amounts | Type, positive, max ceiling | Final price - always fetch from DB |
| User roles | Enum of allowed values | Role assignment - set server-side |
| File paths | No .., no absolute paths |
Any path that accesses the filesystem |
| HTML content | Sanitize with DOMPurify | Raw HTML rendered without sanitization |
| Dates | Valid date format, range limits | Timestamps used in auth/billing logic |
| Free text | Max length, encoding | - |
Validation in Next.js with Supabase: Common Patterns
Apps built with Next.js and Supabase often have two layers where input can enter the system: API routes (Route Handlers) and Server Actions. Both need validation. A common mistake is validating at the Route Handler but skipping validation in Server Actions, on the assumption that they're "internal."
Server Actions are callable from the client. Anything a user can click can trigger a Server Action. Treat them exactly like API endpoints and apply the same Zod schema validation:
// ✅ GOOD - Zod in a Next.js Server Action
'use server';
import { z } from 'zod';
const UpdateProfileSchema = z.object({
displayName: z.string().min(1).max(50),
bio: z.string().max(300).optional(),
});
export async function updateProfile(formData: FormData) {
const result = UpdateProfileSchema.safeParse({
displayName: formData.get('displayName'),
bio: formData.get('bio'),
});
if (!result.success) {
return { error: result.error.flatten().fieldErrors };
}
// Proceed with validated data
}
How to Find Unvalidated Routes in Your Codebase
Search your project for req.body, request.json(), req.query, and req.params. For every occurrence, check whether the immediately following code includes a schema validation call (safeParse, parse, validate). If the data flows directly into a database call, conditional, or business logic without a validation step, you have an unvalidated route.
Tools like VibeDoctor (vibedoctor.io) automatically scan your codebase for missing input validation on API routes and flag specific file paths and line numbers. Free to sign up.
Zod pairs naturally with TypeScript inference - once you define a schema, you get the validated type for free: type CreateUserInput = z.infer<typeof CreateUserSchema>. This means validation also improves your type safety, giving you two benefits from one addition.
FAQ
What's the difference between Zod and Joi?
Zod is TypeScript-first with built-in type inference, making it the standard choice for Next.js and TypeScript projects. Joi is JavaScript-first (no built-in TypeScript types) and has been the standard in Express ecosystems for years. Both work well; choose Zod for TypeScript projects and Joi for plain JavaScript or existing Express codebases.
Doesn't TypeScript already validate my types?
TypeScript validates types at compile time on code you write. At runtime, request.json() returns any - TypeScript has no idea what came over the network. Zod validates the actual runtime values and narrows the TypeScript type simultaneously, which is why the combination is so powerful.
Do I need validation if I'm using Supabase Row Level Security?
RLS and input validation serve different purposes. RLS controls who can read/write which rows. Input validation controls what data shape is accepted. You need both: RLS stops unauthorized access, validation stops malformed data, business logic attacks (like setting your own price), and injection attempts. RLS alone does not protect you from a user sending role: "admin" in a create request.
Should I validate on the client side too?
Client-side validation improves user experience (immediate feedback) but provides zero security. Anyone can bypass browser-side checks by sending a raw HTTP request. Always validate on the server, treat client-side validation as UX only, and never rely on it as a security control.
How do I handle validation errors in a user-friendly way?
Use Zod's safeParse() instead of parse() to avoid thrown exceptions. Return a 400 status with the structured error object from result.error.flatten().fieldErrors, which maps each field name to its error messages. On the client, display these per-field errors next to the relevant form inputs.