Skip to content

Input Validation Best Practices

💡
Before you start

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
⚠️
The cardinal rule of web security

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>
⚠️
Client-side validation is for UX, not security

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)
💡
Best practice: use both

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.

Allowlist (Whitelist) Define exactly what IS permitted and reject everything else. Example: "Accept only letters, numbers, and underscores in usernames." This is the preferred approach because unknown inputs are rejected by default.
Denylist (Blacklist) Define what is NOT permitted and accept everything else. Example: "Reject usernames containing <, >, and ;." This is fragile because attackers constantly find new characters and encodings that bypass deny rules.
# 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.
🎉
Always prefer allowlists

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'
⚠️
Never trust the file extension alone

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 are not sanitization

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.

HTML Context Encode < > & " ' as HTML entities. Example: <p>{{ user_input | escape }}</p>
JavaScript Context JSON-encode data before embedding in script blocks. Never concatenate user input into JavaScript strings.
URL Context Percent-encode special characters in URL parameters. Example: encodeURIComponent(userInput)
CSS Context Avoid placing user input in CSS entirely. If necessary, strictly allowlist values (e.g., only specific color names).
<!-- 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).
⚠️
Beware of double encoding

If you encode data and then encode it again, it can result in garbled output or, worse, security bypasses. For example, encoding < to &lt; and then encoding again produces &amp;lt; which the browser renders as the literal text &lt; 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.

1
Validate a number: convert, then bound

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.

2
Write a denylist, and watch it fail

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.

3
Replace it with an allowlist — describe good, reject the rest

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.

4
Block path traversal: validate the RESOLVED path, not the text

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.

5
Prove where validation has to live: on the server

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.

🎉
Check yourself before moving on

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
🎉
Your code is now harder to exploit!

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.