You need Python 3 and a text editor — nothing else, and no
internet. Every script below is a few lines you run locally. Check
with python3 --version in a terminal (on Windows,
py --version); any 3.x is fine. If it prints nothing useful, do
Introduction to
Python first — about fifteen minutes.
🔴 Everything here runs on your own machine, against files you create. The validation ideas apply to any language and any web framework; Python is just the shortest way to see them work.
Why Validate Input?
Every piece of data that enters your application from an external source is a potential attack vector. User input, query parameters, HTTP headers, file uploads, API payloads, and even data from your own database (if it was stored without validation) can all carry malicious content.
Input validation is the first line of defense in the secure coding chain. Without it, your application becomes vulnerable to an entire class of injection attacks.
- SQL Injection - Malicious SQL statements inserted through form fields to manipulate or extract database content
- Cross-Site Scripting (XSS) - JavaScript injected into pages that executes in other users' browsers
- Command Injection - Operating system commands embedded in input that execute on the server
- Path Traversal - File paths crafted to access files outside the intended directory (e.g.,
../../etc/passwd) - LDAP Injection - Malicious LDAP queries injected through search or authentication fields
- Data Corruption - Invalid data that breaks application logic, causes crashes, or corrupts stored records
Never trust user input. Every value that originates from outside your application must be validated, sanitized, and handled as potentially hostile. This applies to form fields, URL parameters, cookies, HTTP headers, file uploads, and API request bodies.
Client-Side vs. Server-Side Validation
Validation can happen in two places: the browser (client-side) and the server (server-side). Both serve different purposes, and understanding the distinction is critical to building secure applications.
Client-Side Validation
Client-side validation runs in the user's browser using HTML attributes or JavaScript. It provides immediate feedback and improves user experience, but it is trivially bypassable.
<!-- HTML5 built-in validation -->
<form>
<input type="email" required maxlength="254"
pattern="[a-z0-9._%+-]+@[a-z0-9.-]+\.[a-z]{2,}$"
title="Enter a valid email address" />
<input type="number" min="1" max="100" step="1" required />
<input type="text" minlength="3" maxlength="50" required />
<button type="submit">Submit</button>
</form>
An attacker can bypass all client-side validation by disabling JavaScript, using browser developer tools, sending requests directly with curl, or using an intercepting proxy like Burp Suite. Client-side validation must never be your only defense.
Server-Side Validation
Server-side validation is your actual security boundary. It runs in your backend code where the attacker cannot modify it. Every input must be validated server-side before it is processed, stored, or passed to another system.
# Python/Flask server-side validation example
from flask import request, abort
import re
@app.route('/register', methods=['POST'])
def register():
email = request.form.get('email', '').strip()
username = request.form.get('username', '').strip()
age = request.form.get('age', '')
# Validate email format
if not re.match(r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$', email):
abort(400, 'Invalid email format')
# Validate email length
if len(email) > 254:
abort(400, 'Email too long')
# Validate username: alphanumeric, 3-30 chars
if not re.match(r'^[a-zA-Z0-9_]{3,30}$', username):
abort(400, 'Username must be 3-30 alphanumeric characters')
# Validate age: integer in range
try:
age_int = int(age)
if not (13 <= age_int <= 120):
abort(400, 'Age must be between 13 and 120')
except ValueError:
abort(400, 'Age must be a number')
# All validation passed - proceed with registration
create_user(email, username, age_int)
Use client-side validation for a responsive user experience (instant feedback on typos and formatting errors) and server-side validation for security. The two are complementary, not interchangeable.
Allowlists vs. Denylists
When defining what input to accept or reject, you have two fundamental approaches. The difference between them has significant security implications.
# GOOD: Allowlist approach - only permit known-safe characters
import re
def validate_username(username):
"""Only allow alphanumeric characters and underscores, 3-30 chars."""
if re.match(r'^[a-zA-Z0-9_]{3,30}$', username):
return True
return False
# BAD: Denylist approach - try to block known-dangerous characters
def validate_username_bad(username):
"""Block dangerous characters. FRAGILE - attacker will find bypasses."""
dangerous = ['<', '>', '"', "'", ';', '&', '|', '`', '$', '\\']
for char in dangerous:
if char in username:
return False
return True # Accepts EVERYTHING else - unicode tricks, null bytes, etc.
Allowlists are fundamentally more secure than denylists. With an allowlist, any input you did not explicitly anticipate is automatically rejected. With a denylist, any input you did not explicitly anticipate is automatically accepted - which is the exact opposite of what you want for security.
Validating Common Formats
Here are secure validation patterns for data types you will encounter repeatedly. Each example uses an allowlist approach with strict bounds checking.
Email Addresses
import re
def validate_email(email):
"""Validate email format. For real verification, send a confirmation email."""
if not email or len(email) > 254:
return False
# RFC 5322 simplified - covers 99% of real addresses
pattern = r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$'
return bool(re.match(pattern, email))
URLs
from urllib.parse import urlparse
def validate_url(url):
"""Accept only http/https URLs. Prevents javascript:, data:, file: schemes."""
try:
parsed = urlparse(url)
# Allowlist: only http and https schemes
if parsed.scheme not in ('http', 'https'):
return False
# Must have a hostname
if not parsed.netloc:
return False
return True
except Exception:
return False
Numeric Values
def validate_integer(value, min_val, max_val):
"""Validate that a string represents an integer within bounds."""
try:
num = int(value)
return min_val <= num <= max_val
except (ValueError, TypeError):
return False
def validate_price(value):
"""Validate a decimal price: positive, max 2 decimal places, max $999,999."""
import decimal
try:
price = decimal.Decimal(value)
if price <= 0 or price > 999999:
return False
if price.as_tuple().exponent < -2:
return False # More than 2 decimal places
return True
except decimal.InvalidOperation:
return False
File Uploads
import os
import magic # python-magic library
ALLOWED_EXTENSIONS = {'.jpg', '.jpeg', '.png', '.gif', '.pdf'}
MAX_FILE_SIZE = 5 * 1024 * 1024 # 5MB
def validate_file_upload(file):
"""Validate uploaded file by extension, MIME type, and size."""
# Check file size
file.seek(0, os.SEEK_END)
size = file.tell()
file.seek(0)
if size > MAX_FILE_SIZE:
return False, 'File too large (max 5MB)'
# Check extension (allowlist)
ext = os.path.splitext(file.filename)[1].lower()
if ext not in ALLOWED_EXTENSIONS:
return False, f'File type {ext} not allowed'
# Verify MIME type matches extension (prevents extension spoofing)
mime = magic.from_buffer(file.read(2048), mime=True)
file.seek(0)
allowed_mimes = {
'.jpg': 'image/jpeg', '.jpeg': 'image/jpeg',
'.png': 'image/png', '.gif': 'image/gif',
'.pdf': 'application/pdf'
}
if mime != allowed_mimes.get(ext):
return False, 'File content does not match extension'
return True, 'Valid'
An attacker can rename malware.exe to photo.jpg. Always
verify the actual file content (MIME type / magic bytes) in addition to the extension.
Store uploaded files outside the web root and serve them through a controller that
sets correct Content-Type headers.
Sanitization Techniques
Sanitization transforms input to make it safe, as opposed to validation which rejects unsafe input entirely. The two are complementary. Validate first (reject obviously bad input), then sanitize what passes validation before using it.
String Trimming and Normalization
def sanitize_string(value):
"""Basic string sanitization."""
if not isinstance(value, str):
return ''
# Strip leading/trailing whitespace
value = value.strip()
# Remove null bytes (used in null byte injection attacks)
value = value.replace('\x00', '')
# Normalize unicode to prevent homoglyph attacks
import unicodedata
value = unicodedata.normalize('NFKC', value)
# Collapse multiple spaces into one
value = ' '.join(value.split())
return value
HTML Sanitization
# Using the bleach library (Python) for safe HTML
import bleach
def sanitize_html(user_html):
"""Allow only safe HTML tags and attributes."""
allowed_tags = ['p', 'br', 'strong', 'em', 'ul', 'ol', 'li', 'a', 'code']
allowed_attrs = {'a': ['href', 'title']}
allowed_protocols = ['http', 'https']
return bleach.clean(
user_html,
tags=allowed_tags,
attributes=allowed_attrs,
protocols=allowed_protocols,
strip=True # Remove disallowed tags entirely (don't escape them)
)
# Example:
# Input: '<p>Hello</p><script>alert("xss")</script><img onerror="hack()">'
# Output: '<p>Hello</p>'
SQL Parameterization (Not Sanitization)
# NEVER do this - string concatenation with user input
query = f"SELECT * FROM users WHERE username = '{username}'" # VULNERABLE
# ALWAYS use parameterized queries
cursor.execute("SELECT * FROM users WHERE username = %s", (username,))
# With an ORM (SQLAlchemy example)
user = session.query(User).filter(User.username == username).first()
Parameterized queries (prepared statements) separate data from code at the database protocol level. The database engine never interprets user data as SQL. This is fundamentally different from trying to sanitize SQL characters out of input, which is fragile and error-prone. Always use parameterized queries for database operations.
Encoding Output
Output encoding is the complement to input validation. Even if you validate and sanitize input perfectly, you must encode it appropriately when rendering it in different contexts. The encoding you need depends on where the data appears in the output.
< > & " ' as HTML entities. Example: <p>{{ user_input | escape }}</p>
encodeURIComponent(userInput)
<!-- HTML encoding - use your template engine's auto-escape -->
<!-- Jinja2 (Python): auto-escapes by default -->
<p>Welcome, {{ username }}</p>
<!-- Safe JavaScript embedding -->
<script>
// GOOD: JSON-encode server data into a variable
const userData = {{ user_data | tojson }};
// BAD: Direct string interpolation
// const name = '{{ username }}'; // XSS if username contains quotes
</script>
<!-- URL encoding -->
<a href="/search?q={{ query | urlencode }}">Search results</a>
Common Mistakes
Even developers who understand input validation in theory frequently make these mistakes in practice. Each of these has led to real-world security breaches.
- Validating only on the client side - JavaScript validation is a UX feature, not a security control. An attacker sends requests directly to your server endpoint and bypasses all browser-side checks.
- Using denylists instead of allowlists - Blocking
<script>tags but forgetting about<img onerror=...>,<svg onload=...>, or Unicode encoding tricks. Attackers always find what you forgot to block. - Trusting hidden form fields - Hidden fields (
<input type="hidden">) are trivially editable. A hidden field containing a user ID or price is just as attackable as a text input. - Validating input but not encoding output - Input validation and output encoding serve different purposes. Even validated data must be encoded for the context where it is rendered (HTML, JavaScript, SQL, URL).
- Inconsistent validation - Validating input at the web controller but not at the API endpoint, or validating on insert but not on update. Every entry point needs validation.
- Rolling your own sanitization for SQL - Writing custom functions to escape quotes instead of using parameterized queries. This approach has been broken countless times and will be broken again.
- Forgetting about HTTP headers and cookies - Validating form fields but directly using
User-Agent,Referer, or cookie values without sanitization. These are attacker-controlled. - Insufficient length limits - Allowing megabytes of data in a "name" field, enabling denial-of-service through storage exhaustion or expensive regex processing (ReDoS).
If you encode data and then encode it again, it can result in garbled output or,
worse, security bypasses. For example, encoding < to <
and then encoding again produces &lt; which the browser renders as
the literal text < instead of a less-than sign. Apply encoding exactly
once, at the point of output.
Now Do It Yourself: Five Steps
You will validate a number the right way, watch a denylist wave through the exact attacks it was written to stop, replace it with an allowlist that does not, and then block a real path-traversal attempt by validating the resolved path rather than the raw text. Every output below was produced by running this code on Python 3.12; the ideas are identical in any language.
Go: open a terminal, make a folder with mkdir inval-lab and cd inval-lab, and open your text editor.
Do: save these nine lines as age.py, then run python3 age.py.
raw = "25x"
try:
age = int(raw)
except ValueError:
print("Rejected: not a whole number")
else:
if 0 <= age <= 120:
print("Accepted:", age)
else:
print("Rejected: out of range")
You should see: Rejected: not a whole number. The input looks almost like 25, and that is the point: a form field hands you a string, never a number, so the first job is to convert it and let the conversion fail loudly. Change raw to "25" and you get Accepted: 25; change it to "999" and you get Rejected: out of range. Two independent checks — is it a number at all, and is it a number you allow — and both must pass.
If not: if "999" is accepted, your range test is missing or the bounds are wrong. One surprise worth knowing: int(" 30 ") succeeds — Python strips surrounding spaces — so if you need to reject padded input, check for it before converting. Never assume the string is clean because int() did not complain.
Go: same folder.
Do: save these eight lines as denylist.py, then run it.
def looks_safe_denylist(name):
for bad in ["<script>", "../"]:
if bad in name:
return False
return True
for candidate in ["alice", "<SCRIPT>alert(1)</SCRIPT>", "..%2f..%2fetc"]:
print(repr(candidate), "->", "passed denylist" if looks_safe_denylist(candidate) else "blocked")
You should see: all three lines say passed denylist — including the attacks. The denylist looks for lowercase <script>, so the uppercase <SCRIPT> sails past; it looks for literal ../, so the URL-encoded ..%2f sails past too. This is the defining weakness of denylists: you have to think of every disguise the attacker might use, and they only have to find the one you forgot.
If not: if something is blocked, you added a variant to the bad list — which proves the point rather than fixing it. There is always another encoding: mixed case, double URL-encoding, Unicode look-alikes, null bytes. You cannot enumerate “all bad input”, which is exactly why the next step stops trying.
Go: same folder.
Do: save these seven lines as user.py, then run it.
import re
def valid_username(name):
return re.fullmatch(r"[a-z0-9_]{3,16}", name) is not None
for candidate in ["alice", "<SCRIPT>alert(1)</SCRIPT>", "..%2f..%2fetc", "Alice", "ab"]:
print(repr(candidate), "->", "OK" if valid_username(candidate) else "rejected")
You should see: 'alice' -> OK, and every other line rejected — the two attacks from step 2, and also Alice (a capital is not on the allowlist) and ab (too short). You did not list a single bad pattern. You described exactly what a username is — three to sixteen lowercase letters, digits or underscores — and fullmatch rejects anything that is not precisely that, whatever disguise it wears. An allowlist is finite and you control it; a denylist is infinite and the attacker controls it.
If not: if <SCRIPT> is accepted, you used re.match or re.search instead of re.fullmatch. Those match a prefix or a substring, so alice<script> would pass a match. Allowlisting only works if the pattern must match the whole string end to end — that is what fullmatch guarantees and the other two do not.
Go: same folder. First run mkdir uploads and put a file in it: echo hello > uploads/report.txt. Then create a file to protect one level up: echo SECRET > secret.txt.
Do: save these twelve lines as path.py, then run it.
import os
BASE = os.path.realpath("uploads")
def resolve(user_supplied):
full = os.path.realpath(os.path.join(BASE, user_supplied))
if not (full == BASE or full.startswith(BASE + os.sep)):
return None
return full
for req in ["report.txt", "../secret.txt", "subdir/../report.txt", "/etc/passwd"]:
r = resolve(req)
print(repr(req), "->", "BLOCKED" if r is None else os.path.basename(r))
You should see: report.txt, then BLOCKED, then report.txt, then BLOCKED. Read the third and fourth lines together: subdir/../report.txt is allowed because once resolved it lands back inside uploads, while ../secret.txt and the absolute /etc/passwd are refused because they resolve outside it. The lesson is the order: realpath first, collapsing every .. and symlink, then check the answer is inside your base. Checking the raw text for ../ would have blocked the harmless third case and, as step 2 showed, missed an encoded one.
If not: if ../secret.txt is allowed, your prefix check is comparing against BASE without the trailing separator — a sibling folder called uploads-evil would then pass, because its path also starts with the letters of uploads. Comparing against BASE + os.sep (with the explicit == BASE for the folder itself) is what closes that gap.
Go: same folder.
Do: save these six lines as boundary.py and run it — it stands in for a request that never went through your web form at all.
import re
def valid_username(name):
return re.fullmatch(r"[a-z0-9_]{3,16}", name) is not None
attacker_request = "<SCRIPT>alert(1)</SCRIPT>"
print("Server accepted:" if valid_username(attacker_request) else "Server rejected:", attacker_request)
You should see: Server rejected: <SCRIPT>alert(1)</SCRIPT>. The value never touched your HTML form, its maxlength, or its JavaScript checks — because an attacker sends bytes straight to your endpoint with curl, skipping the browser entirely. Client-side validation is a convenience for honest users (instant feedback, fewer round trips); it is not a security control, because the client is the attacker’s machine. The same valid_username running here, on the server, is the check that actually holds.
If not: there is nothing to break here — the point is what is absent: no form, no browser, no client code, and the input still gets validated. If you find yourself trusting a length limit or a dropdown from the front end, this is the step to reread: everything from the client arrives as a suggestion, and the server decides.
Without scrolling up: in step 2 the denylist blocked ../ but let
..%2f through. In one sentence, why does an allowlist not have that
problem? Answer: an allowlist accepts only input that matches a shape you
defined, so anything that is not that shape — in any encoding, case or
disguise — is rejected by default, without your having to predict it.
Now do it without the page: write an allowlist validator for a
six-digit numeric PIN, then feed it "123456", "12345",
"1234567" and "12 456". Only the first should pass. You
have the shape already — re.fullmatch(r"\d{6}", pin) — and
the point is that fullmatch is doing the work the length check and a
denylist would both have gotten wrong.
Summary
In this tutorial, you learned:
- Why input validation is the first line of defense against injection attacks and data corruption
- The critical difference between client-side validation (UX) and server-side validation (security)
- Why allowlists are fundamentally more secure than denylists for defining acceptable input
- How to validate common formats including emails, URLs, numbers, and file uploads
- Sanitization techniques for strings, HTML content, and why parameterized queries replace SQL sanitization
- Context-dependent output encoding for HTML, JavaScript, URL, and CSS contexts
- The most common input validation mistakes and how to avoid them
Consistent input validation, combined with output encoding and parameterized queries, eliminates entire classes of vulnerabilities. Make validation a habit at every entry point in your application, and use your framework's built-in validation tools whenever possible.