| name | Backend Validation |
| description | ENFORCE server-side input validation at the boundary layer before data reaches business logic or database. Use Zod 4 (TS/Node), Pydantic (Python), or go-playground/validator (Go). Never trust client-side validation. Prevent mass assignment, type confusion, and buffer exhaustion. Trigger: "create an API endpoint", "handle a form submission", "save user data", "build a backend route".
|
| category | backend |
| version | 3.0.0 |
| last_updated | 2026-06-28T00:00:00.000Z |
| stacks | ["Node.js","Express","Next.js 16 (Server Actions)","Nuxt","FastAPI","Django"] |
| related_skills | ["api-route-structure","production-api-error-handling","database-schema-design"] |
Backend Validation Layers
IDENTIFY: When to Activate
Activate whenever code RECEIVES input from an external source:
- User-submitted form data or API payloads
- Third-party webhooks
- URL parameters or query strings
- File uploads
CORE PRINCIPLE
Validate at the boundary, then trust internally. Every incoming payload MUST be checked against a schema before any business logic runs. Invalid input gets an immediate 400-class response, execution does NOT continue past the validation layer.
LIBRARY SELECTION (2026)
| Stack | Library | Version | Key Feature |
|---|
| TypeScript / Node.js | Zod | v4 | Schema + parse in one step. z.object({...}).strict() |
| Python | Pydantic | v2 | Class-based models with type annotations |
| Go | go-playground/validator | v10 | Struct tags for validation rules |
| Ruby | ActiveModel::Validations | (built-in) | Built into Rails |
EXECUTE: Instructions
Step 1: Define Schema Before Route Handler
Define the schema as a separate, named export. Do NOT inline it in the route handler.
export const CreateUserSchema = z.object({
email: z.string().email().max(255),
name: z.string().min(1).max(100),
age: z.number().int().min(13).max(150).optional(),
role: z.enum(['user', 'editor']).default('user'),
}).strict();
app.post('/users', async (req, res) => {
const schema = z.object({ ... }).strict();
});
RULES:
- ALWAYS set
.max() on string fields, prevents buffer exhaustion (10MB string can crash server)
- ALWAYS use
.strict() or .strip(), rejects/strips unknown fields. Primary mass assignment defense.
- ALWAYS set
.min() and .max() on numeric fields
- ALWAYS use
.default() for optional fields with sensible defaults
Step 2: Parse at the Boundary: Before Any Other Logic
Validation MUST be the absolute first operation. Do NOT fetch data, check auth, or log before validation.
function validate(schema: z.ZodSchema) {
return (req: Request, res: Response, next: NextFunction) => {
const result = schema.safeParse(req.body);
if (!result.success) {
return res.status(400).json({
error: 'Validation failed',
details: result.error.flatten().fieldErrors,
});
}
req.body = result.data;
next();
};
}
app.post('/api/users', validate(CreateUserSchema), authenticate, async (req, res) => {
const user = await db.user.create({ data: req.body });
});
Step 3: Return Structured Field-Level Errors
ALWAYS return errors the client can map to form fields:
{
"error": "Validation failed",
"details": {
"email": ["Invalid email format"],
"name": ["Required", "Must be at most 100 characters"]
}
}
"Email is invalid"
Step 4: Only Use Validated Data Downstream
After validation, NEVER reference the raw input. Use ONLY the parsed result:
await db.user.create({ data: req.body });
const validated = CreateUserSchema.parse(req.body);
await db.user.create({ data: validated });
Stack-Specific Patterns
Next.js 16 Server Actions (with Zod 4)
'use server';
import { z } from 'zod';
const CreateUserSchema = z.object({
email: z.string().email(),
name: z.string().min(1),
});
export async function createUser(formData: FormData) {
const raw = Object.fromEntries(formData);
const parsed = CreateUserSchema.safeParse(raw);
if (!parsed.success) {
return { success: false, error: 'Validation failed', details: parsed.error.flatten().fieldErrors };
}
const user = await db.user.create({ data: parsed.data });
return { success: true, data: user };
}
FastAPI / Pydantic
from pydantic import BaseModel, EmailStr, Field
class CreateUserRequest(BaseModel):
email: EmailStr
name: str = Field(min_length=1, max_length=100)
age: int | None = Field(default=None, ge=13, le=150)
model_config = {"extra": "forbid"}
@router.post("/users", status_code=201)
async def create_user(body: CreateUserRequest):
user = await db.user.create(body.model_dump())
return {"data": user}
Validate Beyond Request Bodies
| Input Source | What to Validate | Example |
|---|
| URL parameters | Format, existence | GET /api/users/:id, id is valid UUID |
| Query strings | Range, bounds | ?page=3&limit=20, page ≥ 1, limit 1-100 |
| File uploads | Type, size, scan | Image ≤ 5MB, JPEG/PNG/WebP only |
| Webhook payloads | Schema conformance | Validate against expected shape before processing |
| Headers | Format, presence | Content-Type, Authorization format |
VALIDATE: Quality Gates
ANTI-PATTERNS: ALWAYS Avoid
| Anti-Pattern | Why Wrong | Fix |
|---|
if (typeof req.body.age !== 'number') | Manual type checking, verbose, error-prone | Use schema library (Zod, Pydantic) |
await db.user.create({ data: req.body }) | Mass assignment, attacker sets any field | Validate first, pass only parsed data |
| Validating only on client | Trivially bypassed. Zero security. | ALWAYS validate server-side |
| Generic 500 for validation errors | Client can't fix what it can't identify | Return 400 with field-level details |
| No string length limits | 10MB string crashes server | ALWAYS .max() on string fields |
OUTPUT: What This Skill Produces
interface ValidationSetup {
library: 'zod' | 'pydantic' | 'validator' | 'activemodel';
schemas: Array<{
name: string;
fields: Array<{ name: string; type: string; rules: string[] }>;
strict: boolean;
}>;
errorFormat: {
envelope: { error: string; details: Record<string, string[]> };
};
}