15.10 Input Validation
Table of Contents
Why Input Validation Matters
Input validation is checking that any data coming into your backend is what you expect, before you use it.
Every external input is potentially dangerous:
- HTTP request body
- Query parameters
- Path parameters
- Headers
- Cookies
- Uploaded files
- Environment variables (in some contexts)
- Data from other services or queues
If you do not validate, attackers can:
- Trigger bugs and crashes
- Bypass business rules
- Exploit vulnerabilities such as SQL injection or XSS
- Cause performance issues with huge or complex inputs
Rule: Treat all external input as untrusted and validate it before using it.
Throughout this chapter, examples use Python-like pseudocode and JSON, but the same ideas apply to any backend language or framework.
Types of Input Validation
There are several ways to validate input. You usually combine them.
Whitelisting vs Blacklisting
- Whitelisting: define what is allowed, reject everything else.
- Example: "username can only contain letters, digits, and underscores, length 3 to 20".
- Blacklisting: define what is forbidden, allow everything else.
- Example: "reject input containing
<script>".
Whitelisting is much safer.
Rule: Prefer whitelisting (allow known good patterns) over blacklisting (forbid known bad patterns).
Simple Example
Whitelisting username:
import re
USERNAME_RE = re.compile(r"^[A-Za-z0-9_]{3,20}$")
def validate_username(username: str) -> bool:
return bool(USERNAME_RE.match(username))Blacklisting dangerous substring:
def is_safe_comment(comment: str) -> bool:
return "<script>" not in comment.lower()
The second example is weak. Attackers can use variations like <ScRiPt> or other ways to execute scripts.
Syntactic vs Semantic Validation
- Syntactic validation checks the shape of input.
- Example: "email must contain
@", "age must be an integer". - Semantic validation checks the meaning relative to your rules.
- Example: "age must be 18 or older", "booking date must be in the future".
You need both.
Examples
Syntactic:
def validate_age(value: str) -> int:
try:
age = int(value)
except ValueError:
raise ValueError("age must be an integer")
return ageSemantic:
def check_age(age: int) -> None:
if age < 18:
raise ValueError("age must be at least 18")Use syntactic validation first, then semantic.
Client-Side vs Server-Side Validation
- Client-side validation happens in the browser or mobile app.
- Server-side validation happens in your backend.
Client-side validation improves user experience, but cannot be trusted. Users can bypass it with:
- Developer tools
- Custom scripts
- Automated tools like curl or Postman
Rule: Never rely on client-side validation alone. Always validate again on the server.
Example:
- Browser form uses HTML
requiredandtype="email". - Backend still checks:
- Field is present.
- It is a string.
- It matches email pattern.
- It meets business rules.
Validation by Input Type
Different types of input need different checks.
Numbers
Typical checks:
- Is it really a number?
- Is it within expected range?
- Is it integer or float?
- Is sign allowed (positive only, etc.)?
Example: price field.
def validate_price(value: str) -> float:
try:
price = float(value)
except ValueError:
raise ValueError("price must be a number")
if price < 0:
raise ValueError("price cannot be negative")
if price > 1_000_000:
raise ValueError("price too large")
return priceFor counts or IDs, use integers and disallow negatives:
def validate_quantity(value: str) -> int:
try:
quantity = int(value)
except ValueError:
raise ValueError("quantity must be an integer")
if quantity < 1:
raise ValueError("quantity must be at least 1")
if quantity > 1000:
raise ValueError("quantity too large")
return quantityStrings
For string fields:
- Define min and max length.
- Define allowed characters or patterns.
- Decide if whitespace is allowed or should be trimmed.
- Be careful with extremely large strings.
Example: username.
import re
USERNAME_RE = re.compile(r"^[A-Za-z0-9_]{3,20}$")
def validate_username(username: str) -> str:
username = username.strip()
if not USERNAME_RE.match(username):
raise ValueError("username must be 3-20 characters, letters, numbers, underscore only")
return usernameExample: free text comment, where you allow almost anything but limit size:
def validate_comment(comment: str) -> str:
comment = comment.strip()
if len(comment) == 0:
raise ValueError("comment cannot be empty")
if len(comment) > 5000:
raise ValueError("comment too long")
return commentEnums and Controlled Values
Some fields should only allow a small set of values.
Examples:
- User role:
["user", "admin", "moderator"] - Order status:
["pending", "paid", "shipped", "cancelled"] - Sort direction:
["asc", "desc"]
Rule: For fields with limited options, use enums or explicit lists, not free text.
Example:
ALLOWED_ROLES = {"user", "admin", "moderator"}
def validate_role(role: str) -> str:
role = role.strip().lower()
if role not in ALLOWED_ROLES:
raise ValueError("invalid role")
return roleThis also helps prevent logical bugs and privilege escalation.
Dates and Times
For dates and times:
- Parse using a strict format.
- Check semantic rules.
- Consider timezone handling.
Example: parse ISO 8601 date and ensure it is not in the past.
from datetime import datetime, date
def validate_booking_date(value: str) -> date:
try:
parsed = datetime.fromisoformat(value)
except ValueError:
raise ValueError("invalid date format, expected ISO 8601")
today = date.today()
if parsed.date() < today:
raise ValueError("booking date cannot be in the past")
return parsed.date()Also consider:
- Maximum future date (for example, not more than 1 year ahead).
- Consistent timezone (store everything in UTC where possible).
Emails, URLs, and Other Structured Text
Emails, URLs, phone numbers, etc. have structure.
For security purposes, a simple pattern is often enough. Do not try to implement the full email standard.
import re
EMAIL_RE = re.compile(r"^[^@]+@[^@]+\.[^@]+$")
def validate_email(email: str) -> str:
email = email.strip().lower()
if not EMAIL_RE.match(email):
raise ValueError("invalid email address")
if len(email) > 254:
raise ValueError("email too long")
return emailYou still need to verify emails via a confirmation link when needed.
URL
Use a parser instead of complex regex.
from urllib.parse import urlparse
def validate_url(url: str) -> str:
url = url.strip()
parsed = urlparse(url)
if parsed.scheme not in ("http", "https"):
raise ValueError("invalid scheme")
if not parsed.netloc:
raise ValueError("invalid URL, missing host")
return urlFor security, you may also restrict:
- Hostnames (for example, disallow internal IPs like
127.0.0.1or private networks). - Maximum length.
Validating HTTP Inputs
Path and Query Parameters
These come as strings and must be converted and checked.
Example: /users/{user_id} where user_id is an integer:
def validate_user_id(value: str) -> int:
try:
user_id = int(value)
except ValueError:
raise ValueError("user_id must be an integer")
if user_id <= 0:
raise ValueError("user_id must be positive")
return user_id
Example: pagination query params page and page_size:
def validate_pagination(page: str | None, page_size: str | None) -> tuple[int, int]:
page = int(page) if page is not None else 1
page_size = int(page_size) if page_size is not None else 20
if page < 1:
raise ValueError("page must be >= 1")
if not 1 <= page_size <= 100:
raise ValueError("page_size must be between 1 and 100")
return page, page_sizeJSON Request Bodies
For JSON, define a schema: what fields are required, their types, and constraints.
Example: registration request:
{
"email": "user@example.com",
"password": "abc12345",
"name": "John Doe"
}Validation steps:
- Check body is valid JSON.
- Check that required fields exist.
- Check types (string, number, object, etc.).
- Apply rules per field (lengths, formats).
- Reject unknown fields if your API requires strictness.
In Python-like pseudocode:
def validate_registration(body: dict) -> dict:
# required fields
for field in ("email", "password", "name"):
if field not in body:
raise ValueError(f"missing field: {field}")
email = validate_email(body["email"])
password = validate_password(body["password"])
name = validate_name(body["name"])
return {"email": email, "password": password, "name": name}Using frameworks like FastAPI with Pydantic, you will typically express this as a model and let the framework run validation for you. The principle is the same.
Form Data
Form data often comes from HTML forms, usually as strings.
Common pitfalls:
- Missing required fields.
- Multiple values for the same field.
- Encoding issues.
- Hidden fields that attackers can modify.
Treat form fields exactly like any other external input: parse, normalize, validate.
Example: login form:
def validate_login_form(form: dict) -> dict:
username = form.get("username", "").strip()
password = form.get("password", "")
if not username:
raise ValueError("username required")
if not password:
raise ValueError("password required")
if len(username) > 50:
raise ValueError("username too long")
return {"username": username, "password": password}File Uploads
Files are especially risky. Validation involves:
- Type checks:
- Check MIME type from the request header.
- Check file extension.
- Optionally inspect "magic bytes" at the beginning of the file.
- Size limits:
- Per file.
- Total request size.
- Content checks:
- For images, try to open with an image library.
- Reject files with mixed or suspicious content.
Rule: Never trust only the file extension or the client-provided MIME type.
Example of size check:
MAX_AVATAR_SIZE = 2 * 1024 * 1024 # 2 MB
def validate_avatar(file_bytes: bytes, filename: str, content_type: str) -> None:
if len(file_bytes) > MAX_AVATAR_SIZE:
raise ValueError("file too large")
if content_type not in ("image/jpeg", "image/png"):
raise ValueError("unsupported file type")
# Optional: inspect magic bytes or use an image library to verifyAlso consider:
- Normalizing filenames.
- Storing files outside the web root.
- Scanning with antivirus if needed.
Preventing Injection with Validation
Input validation reduces the chance of injection attacks. It is not enough by itself, but it is a strong first layer.
SQL Injection
You already know from the SQL Injection chapter that you must use parameterized queries or ORM features. Validation strengthens this:
- If
user_idmust be an integer, validation rejects"1; DROP TABLE users"before it even reaches the database. - If search queries have length limits, it is harder to send large payloads that trigger parser vulnerabilities.
Example of safe and validated query:
def get_user(db, raw_user_id: str):
user_id = validate_user_id(raw_user_id)
# safe parameterized query
return db.execute("SELECT * FROM users WHERE id = %s", (user_id,))Rule: Always combine validation with parameterized queries or ORM. Do not rely on validation alone.
Cross-Site Scripting (XSS)
Input validation helps reduce XSS but does not replace output encoding.
Typical strategies:
- For fields that should not contain HTML (username, title), restrict allowed characters.
- For free text fields (comments), you may:
- Strip or encode HTML characters, or
- Sanitize HTML with a trusted library if you want to allow some tags.
Examples:
- Username: only letters, numbers, underscore.
- Blog title: letters, numbers, punctuation, limited length.
- Comment: limit length, then ensure proper HTML escaping on output.
Remember, output encoding (for example, HTML escaping) is the main defense against XSS. Validation is an additional barrier.
Safe Defaults and Limits
Validation is also about setting safe boundaries.
Length and Size Limits
Set length limits everywhere you can:
- Path parameters and query parameters.
- Headers.
- JSON fields.
- Form fields.
- Files.
This helps:
- Prevent denial of service from huge payloads.
- Avoid unexpected behavior in downstream systems.
Example policies (these are examples, not universal rules):
| Item | Example limit |
|---|---|
| Username | 3 to 30 chars |
| Password | 8 to 128 chars |
| up to 254 chars | |
| Comment body | up to 5,000 chars |
| JSON body size | up to 1 MB |
| Uploaded image | up to 5 MB |
Apply limits close to the entry point if possible, for example:
- Web server or reverse proxy can limit total request size.
- Application can limit per field.
Default Values vs Required Fields
For optional fields you can:
- Provide a safe default value.
- Or treat them as truly optional and handle
null/ missing values explicitly.
Examples:
pagedefault to1if not provided.page_sizedefault to20.- Missing
middle_nameisNone.
Be explicit in your API documentation about what is required and what has defaults.
Error Messages and Security
Validation errors become part of your API responses. They should help legitimate users, but not leak sensitive information.
What to Include
Good error messages:
- Say which field is invalid.
- Say what rule was violated.
- Do not reveal internal details.
Example of a good error for a public API:
{
"error": "validation_error",
"details": [
{"field": "email", "message": "invalid email address"},
{"field": "password", "message": "must be at least 8 characters"},
{"field": "age", "message": "must be an integer >= 18"}
]
}Bad examples:
"Database error: null value in column 'email'"leaks schema info."Traceback (most recent call last): ..."leaks stack traces.
Rule: Validation errors should be clear but generic, and must not expose stack traces, SQL queries, or secrets.
Differentiate validation errors (client errors, status 400) from server errors (status 500).
Centralizing Validation
Instead of scattering manual checks everywhere, centralize validation:
- Use request schemas or models (for example, Pydantic models in FastAPI).
- Use middleware or decorators to validate repeated patterns (pagination, sorting).
- Reuse validators across endpoints.
Benefits:
- Less code duplication.
- Consistent rules.
- Easier audits and updates.
Example Concept with Models
Imagine a user registration model:
from pydantic import BaseModel, EmailStr, constr
class RegisterUser(BaseModel):
email: EmailStr
password: constr(min_length=8, max_length=128)
name: constr(min_length=1, max_length=100)Your framework then:
- Parses JSON into this model.
- Automatically checks types and constraints.
- Returns clear validation errors.
Even if you do not use Pydantic, aim for a similar pattern in your framework of choice.
Practical Checklist for Input Validation
You can apply this quick checklist to each new endpoint:
- Identify all inputs:
- Path params
- Query params
- Headers
- Cookies
- Body (JSON / form)
- Files
- Define schema:
- Required fields and types.
- Min / max length or value.
- Allowed values (enums).
- Exact format (for example, ISO date).
- Add syntactic checks:
- Type conversion.
- Patterns and formats.
- Add semantic checks:
- Business rules (ranges, dates, uniqueness constraints where appropriate).
- Set limits:
- Field-level size limits.
- Total request size limit.
- File size limit.
- Use safe defaults:
- For optional fields like pagination, sorting.
- Return safe errors:
- Explain what is wrong with input.
- Do not leak internals.
- Combine with other defenses:
- Parameterized SQL.
- Output encoding.
- Authentication and authorization checks.
If you apply this consistently, you will prevent a large class of bugs and vulnerabilities before they appear.
Views: 8
KAHIBARO