Input Validation
Status: Complete
Category: Security
Default enforcement: Hard
Author: PushBackLog team
Tags
- Topic: security, quality
- Skillset: backend, frontend
- Technology: generic
- Stage: execution, review
Summary
All input entering a system boundary must be validated against an expected schema before being processed or stored. Validation must occur server-side regardless of any client-side validation present; trusting client input without verification is one of the most common and exploitable security mistakes.
Rationale
The threat model
Any data that arrives at your server from outside your system — HTTP request bodies, query parameters, path parameters, headers, uploaded files, webhook payloads — is attacker-controlled. An attacker can send any payload they choose, bypassing forms, mobile apps, and browser-based validation entirely. Treating unvalidated external data as trustworthy is the root cause of OWASP A05 (Injection) and contributes directly to A01 (Broken Access Control).
Client-side validation is UX, not security
Frontend validation (form field constraints, JavaScript validation) is valuable for user experience — it gives immediate feedback without a round-trip. It provides zero security. Attackers do not use your form; they use curl, Postman, or custom scripts. Every validation on the frontend must also exist on the backend.
Validation is the first line of defence in depth
Input validation is not a complete security solution — parameterised queries, output encoding, and authorisation checks are all independently necessary. But it is the first layer. Data that cannot pass schema validation cannot reach the layers where it might do damage.
Guidance
What to validate at each boundary
| Input source | Validate |
|---|---|
| HTTP request body | Schema (required fields, types, formats, bounds) |
| Query parameters | Presence, type, permitted values, length |
Path parameters (:id) | Format (UUID, numeric), existence, then ownership |
| File uploads | MIME type (from magic bytes, not extension), size limit, filename |
| Webhook payloads | Schema + HMAC signature verification |
| JWT / session tokens | Signature, expiry, issuer, audience |
Validation vs sanitisation
| Operation | Purpose | When to use |
|---|---|---|
| Validation | Reject input that doesn’t meet the expected schema | Always, at every boundary |
| Sanitisation | Transform input to remove dangerous content | For output encoding (HTML, SQL); not a substitute for validation |
| Allowlisting | Accept only known-good values | Preferred for constrained inputs (enums, IDs, codes) |
| Denylisting | Reject known-bad patterns | Fragile — use only when allowlisting is not possible |
Prefer allowlisting. If a field should be a UUID, validate that it is a UUID — don’t search it for SQL keywords.
Schema validation libraries
| Stack | Library |
|---|---|
| TypeScript / Node | Zod, Joi, class-validator |
| Python | Pydantic, voluptuous |
| Java / Kotlin | Jakarta Bean Validation, Valiktor |
| Go | go-playground/validator |
| Ruby | dry-validation |
Validate early, fail loudly
Validation should happen as early as possible in the request lifecycle — ideally in middleware or a dedicated validation layer before any business logic executes. Validation failures should return a clear, safe error response (422 Unprocessable Entity) with no internal detail.
Examples
API endpoint with schema validation (TypeScript + Zod)
import { z } from 'zod';
const CreateOrderSchema = z.object({
userId: z.string().uuid(),
items: z.array(z.object({
productId: z.string().uuid(),
quantity: z.number().int().positive().max(100),
})).min(1).max(50),
discountCode: z.string().max(20).regex(/^[A-Z0-9-]+$/).optional(),
});
app.post('/api/orders', (req, res) => {
const result = CreateOrderSchema.safeParse(req.body);
if (!result.success) {
return res.status(422).json({ errors: result.error.flatten() });
}
// result.data is now type-safe and validated
return orderService.create(result.data);
});
Unknown fields are stripped. Invalid types, out-of-bound values, and malformed UUIDs are all rejected before they reach business logic.
File upload validation
import fileType from 'file-type';
async function validateUpload(file: Buffer, allowedTypes: string[]): Promise<void> {
const detected = await fileType.fromBuffer(file);
if (!detected || !allowedTypes.includes(detected.mime)) {
throw new ValidationError('Invalid file type');
}
if (file.length > 5 * 1024 * 1024) {
throw new ValidationError('File exceeds the 5MB limit');
}
}
Note: MIME type is read from file bytes (magic bytes), not from the extension or Content-Type header — both of which are attacker-controlled.
Anti-patterns
1. Client-only validation
“We validate on the form.” Server-side validation is not optional. No client-side validation provides any server-side protection.
2. Trusting user-supplied IDs for ownership
const { orderId, userId } = req.body; // userId supplied by the client
order = await getOrder(orderId, userId); // Attacker sets userId to someone else's
User identity must come from the authenticated session/token, never from request body.
3. Using unsanitised input in queries or commands
Even with validation in place, any input used in a database query must use parameterisation. Validation reduces attack surface; parameterisation prevents injection.
4. Catching exceptions from downstream instead of validating upfront
Relying on the database to reject invalid data via a constraint violation is not input validation — it’s error recovery. Validation must happen at the boundary, before the database is consulted.
5. Trusting Content-Type headers for file uploads
An attacker can upload a PHP script with Content-Type: image/jpeg. Detect file type from magic bytes only.
Related practices
Part of the PushBackLog Best Practices Library. Suggest improvements →