input-validation-layer
Input Validation Layer
Validation belongs at every entry point — API routes, webhooks, queue consumers, cron job inputs — before any business logic runs. One missed entry point is all an attacker needs.
Principles
- Validate at the boundary — before the data touches your app logic or DB
- Allowlist, not blocklist — define the exact shape you accept; reject everything else
- Never trust — headers, query params, URL params, request body, cookies, webhook payloads
- Fail closed — unknown/extra fields stripped or rejected; never passed through silently
- Single schema = source of truth — same schema used for validation, TypeScript types, and docs
Schema Design (Zod, used throughout)
import { z } from 'zod';
// Define once — derive types from it
export const CreateInvoiceSchema = z.object({
amount: z.number().int().positive().max(1_000_000_00), // cents, no float drift
currency: z.enum(['USD', 'EUR', 'GBP']),
description: z.string().min(1).max(500).trim(),
due_date: z.string().datetime(), // ISO 8601 only
recipient: z.object({
email: z.string().email().toLowerCase(),
name: z.string().min(1).max(100).trim(),
}),
});
// TypeScript type derived — no duplication
export type CreateInvoice = z.infer<typeof CreateInvoiceSchema>;
Strip unknown fields — never let extra fields through to the ORM:
// .strip() is Zod's default — unknown keys silently dropped
// .strict() rejects unknown keys entirely (good for internal APIs)
const StrictSchema = CreateInvoiceSchema.strict();
Centralizing Validation: Middleware Pattern
Write once, apply to any route.
// middleware/validate.ts
import { z, ZodSchema } from 'zod';
import { Request, Response, NextFunction } from 'express';
type Target = 'body' | 'query' | 'params';
export function validate(schema: ZodSchema, target: Target = 'body') {
return (req: Request, res: Response, next: NextFunction) => {
const result = schema.safeParse(req[target]);
if (!result.success) {
return res.status(400).json({
error: 'Validation failed',
issues: result.error.issues.map(i => ({
field: i.path.join('.'),
message: i.message,
})),
});
}
req[target] = result.data; // replace with parsed/coerced/stripped data
next();
};
}
// Usage
app.post('/invoices',
requireAuth,
validate(CreateInvoiceSchema, 'body'),
validate(z.object({ org_id: z.string().uuid() }), 'params'),
createInvoiceHandler,
);
Entry Points to Cover
API routes (above pattern)
Query params — often forgotten
const PaginationSchema = z.object({
page: z.coerce.number().int().min(1).default(1),
limit: z.coerce.number().int().min(1).max(100).default(20),
sort: z.enum(['created_at', 'amount', 'due_date']).default('created_at'),
order: z.enum(['asc', 'desc']).default('desc'),
});
// z.coerce converts "?page=2" (string) → number automatically
app.get('/invoices', requireAuth, validate(PaginationSchema, 'query'), listInvoices);
Webhooks — validate payload + verify signature
const StripeWebhookSchema = z.object({
type: z.string(),
data: z.object({ object: z.record(z.unknown()) }),
});
app.post('/webhooks/stripe',
validateStripeSignature, // verify HMAC before parsing — rejects spoofed payloads
validate(StripeWebhookSchema),
handleStripeWebhook,
);
function validateStripeSignature(req, res, next) {
try {
// stripe.webhooks.constructEvent needs raw body — use express.raw() on this route
stripe.webhooks.constructEvent(req.rawBody, req.headers['stripe-signature'], WEBHOOK_SECRET);
next();
} catch { res.status(400).send('Invalid signature'); }
}
Queue / worker consumers
// Don't trust queue messages any more than HTTP requests
async function processJob(rawPayload: unknown) {
const payload = SendEmailSchema.parse(rawPayload); // throws on invalid
await sendEmail(payload);
}
Environment / config on startup
const EnvSchema = z.object({
DATABASE_URL: z.string().url(),
JWT_PRIVATE_KEY: z.string().min(1),
STRIPE_SECRET: z.string().startsWith('sk_'),
PORT: z.coerce.number().default(3000),
});
const env = EnvSchema.parse(process.env); // fail fast at startup, not at runtime
export default env;
Non-Obvious Things to Validate
| Input | What to enforce |
|---|---|
| Numeric IDs from URL | .uuid() or regex — never trust parseInt alone |
| Enum fields | .enum([...]) — not just z.string() |
| Dates | .datetime() or .coerce.date() — not raw strings used in queries |
| Currency/money | Integer cents — reject floats to avoid drift |
| Redirect URLs | Validate scheme + origin (see xss-prevention / idor-fix skills) |
| File uploads | MIME type, extension, size limit — all three; MIME alone is spoofable |
| Free-text fields | .max() always — unbounded strings can fill DB / trigger DoS |
| Arrays | .array().min(1).max(50) — unbounded arrays = DoS vector |
Validation vs Sanitization
| Validation | Sanitization | |
|---|---|---|
| Purpose | Reject invalid input | Transform input to safe form |
| When | Always, at entry point | Only when format must be preserved |
| Examples | Reject non-email strings | .trim(), .toLowerCase(), strip control chars |
Prefer rejection over transformation for security-sensitive fields. Transforming input silently (e.g., truncating to max) hides bugs and can introduce unexpected behavior.
.trim() in schema is fine for UX. Never silently truncate IDs, tokens, or enum values.
Error Response Shape
Consistent validation error shape across all endpoints:
{
"error": "Validation failed",
"issues": [
{ "field": "recipient.email", "message": "Invalid email" },
{ "field": "amount", "message": "Number must be positive" }
]
}
Don't leak internal details — schema paths are fine; stack traces are not.
Audit Checklist
- Every route has a schema for body, query params, and URL params
- Unknown/extra fields stripped or rejected — not passed to ORM
- Query param numbers use
z.coerce— no raw string-to-number conversion in handlers - Webhook routes verify signature before parsing payload
- Queue/worker consumers validate payloads with same rigor as HTTP routes
- Env vars validated at startup with a schema
- All string fields have
.max()— no unbounded strings - All arrays have
.max()— no unbounded arrays - Enum fields use
.enum()— not justz.string() - Money/currency stored as integers — no floats
- Validation errors return consistent shape without stack traces
More from blunotech-dev/agents
anti-purple-ui
Enforce a strict monochrome UI with a single high-contrast accent color, removing generic tech gradients and “AI-style” palettes. Use when the user wants minimal, anti-AI, or non-generic aesthetics, or says the UI looks too techy or generic.
9harmonize-whitespace
Align all spacing (padding, margins, gaps) to a consistent 4pt/8pt grid. Use when spacing feels off, inconsistent, cramped, or unbalanced, or when the user asks for a spacing scale or alignment fix.
9typographic-hierarchy
Improve typography by adjusting font sizes, weights, spacing, and contrast to create clear visual hierarchy and readability. Use when text feels flat, unstructured, or when the user asks to refine headings, type scale, or overall readability.
6consistent-border-radius
Normalizes rounded corners across a file so buttons, inputs, cards, modals, badges, and all UI elements share the exact same curvature. Use this skill whenever the user mentions inconsistent border radii, wants to unify rounded corners, asks to make UI elements look more cohesive, or says things like "make the corners match", "fix the rounding", "unify border radius", "standardize my rounded corners", or "buttons and cards don't match". Also trigger when the user pastes a CSS/HTML/JSX/TSX file and asks for a design consistency pass, border radius is one of the first things to normalize.
4component-split
Analyze a component and determine when and how to split it based on size, responsibility, and reuse signals, producing a refactored structure with clear boundaries. Use when users share large, mixed-concern, or hard-to-test components, or ask about splitting, refactoring, or improving component architecture.
3tailwind-class-sorter
Sort Tailwind CSS utility classes into a clear, consistent order (layout, spacing, sizing, typography, visual). Use when classes are messy, hard to read, or when the user asks to clean up or organize Tailwind code.
3